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
324 changed files with 46471 additions and 751 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 -2
View File
@@ -119,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 = [
@@ -160,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''',
@@ -167,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

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SaasInvoice;
use Illuminate\Console\Command;
/**
* Помечает просроченные неоплаченные счета статусом overdue (Этап 1 «оплата по счёту»).
* Только issued overdue по expires_at; оплаченные/отменённые не трогаются.
*/
class ExpireInvoicesCommand extends Command
{
protected $signature = 'invoices:expire';
protected $description = 'Помечает просроченные неоплаченные счета статусом overdue';
public function handle(): int
{
SaasInvoice::where('status', SaasInvoice::STATUS_ISSUED)
->where('expires_at', '<', now())
->update(['status' => SaasInvoice::STATUS_OVERDUE]);
return self::SUCCESS;
}
}
@@ -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;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin: список счетов + ручная отметка оплаты (Этап 1 «оплата по счёту»).
* Зона saas-admin/admin-db. Зачисление делегируется InvoicePaymentService
* (идемпотентно, под tenant RLS-контекстом).
*/
class AdminInvoiceController extends Controller
{
public function __construct(private readonly InvoicePaymentService $payments) {}
public function index(Request $request): JsonResponse
{
$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(),
],
]);
}
public function markPaid(Request $request, int $id): JsonResponse
{
$this->payments->markPaid($id);
return response()->json(['status' => 'ok']);
}
}
@@ -30,10 +30,14 @@ class AdminTenantsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', '');
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
$statuses = $this->csvParam($request, 'statuses');
$tariffs = $this->csvParam($request, 'tariffs');
$search = trim((string) $request->query('search', ''));
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
@@ -59,8 +63,22 @@ class AdminTenantsController extends Controller
])
->whereNull('tenants.deleted_at');
if ($status !== '') {
$query->where('tenants.status', $status);
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
if ($statuses !== []) {
$query->whereIn(DB::raw("(CASE
WHEN tenants.is_trial THEN 'trial'
WHEN tenants.status = 'suspended' THEN 'suspended'
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
WHEN tenants.status = 'active' THEN 'active'
ELSE 'suspended'
END)"), $statuses);
} elseif ($status !== '') {
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
}
if ($tariffs !== []) {
$query->whereIn('tariff_plans.name', $tariffs);
}
if ($search !== '') {
$like = '%'.$search.'%';
@@ -451,6 +469,19 @@ class AdminTenantsController extends Controller
];
}
/**
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
*
* @return list<string>
*/
private function csvParam(Request $request, string $key): array
{
return array_values(array_filter(array_map(
'trim',
explode(',', (string) $request->query($key, '')),
)));
}
/**
* Aggregate-stats для page-head: total / active / trial / overdue / revenue.
* Считается отдельным запросом без фильтров (показывает глобальную картину
@@ -307,7 +307,14 @@ class BillingController extends Controller
$rows = DB::table('saas_invoices')
->where('tenant_id', $tenantId)
->orderBy('issued_at', 'desc')
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']);
// Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт».
$actInvoiceIds = DB::table('saas_upd_documents')
->where('tenant_id', $tenantId)
->whereNotNull('invoice_id')
->pluck('invoice_id')
->flip();
return response()->json([
'data' => $rows->map(static fn (\stdClass $r): array => [
@@ -316,7 +323,11 @@ class BillingController extends Controller
'amount_total' => $r->amount_total,
'status' => $r->status,
'issued_at' => $r->issued_at,
'expires_at' => $r->expires_at,
'has_pdf' => $r->pdf_path !== null,
'has_act' => isset($actInvoiceIds[$r->id]),
'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null,
'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null,
])->all(),
]);
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
*/
class InvoiceController extends Controller
{
public function __construct(private readonly InvoiceService $invoices) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'amount_total' => $invoice->amount_total,
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
]], 201);
}
public function pdf(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
}
public function act(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
}
/**
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
*/
private function inlinePdf(string $path, string $baseName): Response
{
$content = Storage::disk('local')->get($path);
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}
@@ -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),
];
}
}
@@ -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);
}
}
@@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
Tenant::query()
// Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS).
// Список замороженных тенантов брать через дефолтное соединение нельзя — без
// app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же
// баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS).
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->whereNotNull('frozen_by_balance_at')
->whereNull('deleted_at')
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->processTenant($tenant, $service, $tiers);
}
});
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->processTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
{
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find,
// и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent()
// идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен.
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$tenant = Tenant::find($tenantId);
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
return; // разморожен/удалён между pluck и обработкой.
}
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
});
}
private function matchWindow(int $hours): ?string
@@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->evaluateTenant($tenant, $service, $tiers);
}
});
// Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user
// (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без
// app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и
// sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id
// через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает
// для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст.
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->whereNull('deleted_at')
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->evaluateTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
{
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
// RLS-policy на projects падает с "unrecognized configuration parameter".
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
DB::transaction(function () use ($tenant, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find
// вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна.
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return; // удалён между pluck и обработкой — пропускаем.
}
$required = $tenant->requiredLeadsForTomorrow();
$result = $service->evaluate(
+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,
+26
View File
@@ -8,9 +8,11 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
/**
* Email-уведомление об оплате тарифного счёта (ТЗ §18.5, событие
@@ -31,6 +33,10 @@ class InvoicePaidNotification extends Mailable
public string $amountRub,
public ?string $invoiceNumber,
public ?string $tariffName,
/** Относительный путь PDF-акта на диске 'local' (для вложения). */
public ?string $actPdfPath = null,
/** Номер акта — для имени файла вложения. */
public ?string $actNumber = null,
) {}
public function envelope(): Envelope
@@ -53,4 +59,24 @@ class InvoicePaidNotification extends Mailable
],
);
}
/**
* Вложение: PDF закрывающего документа (Акт), если он сформирован.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
if ($this->actPdfPath === null) {
return [];
}
$name = 'Akt-'.Str::ascii((string) $this->actNumber).'.pdf';
return [
Attachment::fromStorageDisk('local', $this->actPdfPath)
->as($name)
->withMime('application/pdf'),
];
}
}
@@ -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(),
],
);
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ class LegalEntity extends Model
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
'director_basis', 'vat_mode', 'is_default',
];
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* Счёт на оплату (schema.sql table saas_invoices). RLS по tenant_id.
* Этап 1 «оплата по счёту»: выставляется клиентом, оплачивается банковским
* переводом, отмечается администратором (InvoicePaymentService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $invoice_number
* @property string $payer_type
* @property string|null $payer_name
* @property string|null $payer_inn
* @property string|null $payer_kpp
* @property string|null $payer_address
* @property string|null $payer_email
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
* @property Carbon|null $expires_at
* @property Carbon|null $paid_at
* @property Carbon|null $cancelled_at
*/
class SaasInvoice extends Model
{
public $timestamps = false;
public const STATUS_DRAFT = 'draft';
public const STATUS_ISSUED = 'issued';
public const STATUS_PAID = 'paid';
public const STATUS_OVERDUE = 'overdue';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
'expires_at' => 'datetime',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
/** @return HasMany<SaasInvoiceItem, $this> */
public function items(): HasMany
{
return $this->hasMany(SaasInvoiceItem::class, 'invoice_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Позиция счёта (schema.sql table saas_invoice_items). RLS косвенно через invoice_id.
*
* @property int $id
* @property int $invoice_id
* @property string $name
* @property string|null $okpd2
* @property string $quantity
* @property string $unit
* @property string $price
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
*/
class SaasInvoiceItem extends Model
{
public $timestamps = false;
protected $fillable = [
'invoice_id', 'name', 'okpd2', 'quantity', 'unit',
'price', 'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
];
protected function casts(): array
{
return [
'quantity' => 'decimal:3',
'price' => 'decimal:2',
'amount_net' => 'decimal:2',
'amount_total' => 'decimal:2',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Закрывающий документ (schema.sql table saas_upd_documents). RLS по tenant_id.
* Для УСН без НДС используем upd_function='ДОП' (передаточный документ без
* счёта-фактуры) формируется как Акт об оказании услуг (ActService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $upd_number
* @property string $upd_function
* @property int|null $correction_for
* @property string $buyer_type
* @property string|null $buyer_name
* @property string|null $buyer_inn
* @property string|null $buyer_kpp
* @property string|null $buyer_address
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property int|null $invoice_id
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
*/
class SaasUpdDocument extends Model
{
public $timestamps = false;
protected $table = 'saas_upd_documents';
public const FUNCTION_DOP = 'ДОП';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'upd_number', 'upd_function', 'correction_for',
'buyer_type', 'buyer_name', 'buyer_inn', 'buyer_kpp', 'buyer_address',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
'invoice_id', 'transaction_id', 'pdf_path', 'status', 'issued_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
];
}
}
+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),
),
);
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use Illuminate\Support\Carbon;
/**
* Формирует закрывающий документ (Акт об оказании услуг, без НДС, УСН) по
* оплаченному счёту. Хранится в saas_upd_documents (upd_function=ДОП передаточный
* документ без счёта-фактуры). PDF в приватный storage.
*/
final class ActService
{
public function __construct(private readonly PdfRenderer $pdf) {}
public function createForInvoice(SaasInvoice $invoice, int $transactionId): SaasUpdDocument
{
$seller = LegalEntity::findOrFail($invoice->legal_entity_id);
$now = Carbon::now('Europe/Moscow');
$number = str_replace('СЧ-', 'АКТ-', (string) $invoice->invoice_number);
$act = SaasUpdDocument::create([
'tenant_id' => $invoice->tenant_id,
'legal_entity_id' => $invoice->legal_entity_id,
'upd_number' => $number,
'upd_function' => SaasUpdDocument::FUNCTION_DOP,
'buyer_type' => $invoice->payer_type,
'buyer_name' => $invoice->payer_name,
'buyer_inn' => $invoice->payer_inn,
'buyer_kpp' => $invoice->payer_kpp,
'buyer_address' => $invoice->payer_address,
'amount_net' => $invoice->amount_total,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $invoice->amount_total,
'invoice_id' => $invoice->id,
'transaction_id' => $transactionId,
'status' => 'issued',
'issued_at' => $now,
]);
$path = $this->pdf->renderToStorage('pdf.act', [
'act' => $act,
'seller' => $seller,
'invoiceNumber' => $invoice->invoice_number,
], "acts/{$act->id}-{$number}.pdf");
$act->pdf_path = $path;
$act->save();
return $act;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\SaasInvoice;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Атомарная нумерация счетов: СЧ-ГГГГ-NNNNN, последовательно по legal_entity_id+год.
* Advisory-lock на пару (legal_entity_id, year) сериализует параллельные вызовы;
* UNIQUE (legal_entity_id, invoice_number) в схеме последний барьер от дублей.
* Вызывать ВНУТРИ транзакции (xact-lock держится до COMMIT).
*/
final class InvoiceNumberGenerator
{
public function next(int $legalEntityId, ?Carbon $now = null): string
{
$now ??= Carbon::now('Europe/Moscow');
$year = (int) $now->year;
// Advisory lock на пару чисел (legal_entity_id, year) — освобождается на COMMIT.
DB::statement('SELECT pg_advisory_xact_lock(?, ?)', [$legalEntityId, $year]);
$prefix = sprintf('СЧ-%d-', $year);
$maxNumber = SaasInvoice::query()
->where('legal_entity_id', $legalEntityId)
->where('invoice_number', 'like', $prefix.'%')
->orderByDesc('invoice_number')
->value('invoice_number');
$seq = 1;
if ($maxNumber !== null) {
$seq = ((int) substr((string) $maxNumber, strlen($prefix))) + 1;
}
return sprintf('%s%05d', $prefix, $seq);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Mail\InvoicePaidNotification;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
* зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
*/
final class InvoicePaymentService
{
public function __construct(
private readonly BillingTopupService $topup,
private readonly ActService $acts,
) {}
public function markPaid(int $invoiceId): void
{
$invoice = SaasInvoice::findOrFail($invoiceId);
$credited = DB::transaction(function () use ($invoice): bool {
// RLS-контекст транзакции (PgBouncer-safe SET LOCAL), как в webhook.
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $invoice->tenant_id);
// Атомарно занимаем issued→paid; 0 строк = уже оплачен (дубль/гонка).
$claimed = SaasInvoice::where('id', $invoice->id)
->where('status', SaasInvoice::STATUS_ISSUED)
->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]);
if ($claimed === 0) {
return false; // идемпотентный no-op
}
$tx = SaasTransaction::create([
'tenant_id' => $invoice->tenant_id,
'type' => 'topup',
'amount_rub' => $invoice->amount_total,
'gateway_code' => 'bank_transfer',
'payment_method' => 'bank_transfer',
'legal_entity_id' => $invoice->legal_entity_id,
'invoice_id' => $invoice->id,
'status' => 'success',
'description' => 'Оплата по счёту '.$invoice->invoice_number,
'created_at' => now(),
'completed_at' => now(),
]);
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'balance_transaction_id' => $balanceTx->id,
'upd_id' => $act->id,
]);
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
return true;
});
if (! $credited) {
return;
}
// Письмо — после COMMIT (избегаем отправки при откате транзакции).
// К письму прикладываем PDF-акт (закрывающий документ).
$tenant = Tenant::find($invoice->tenant_id);
$recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first();
if ($tenant !== null && $recipient !== null) {
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->first();
Mail::to($recipient->email)->queue(new InvoicePaidNotification(
$recipient,
$tenant,
(string) $invoice->amount_total,
$invoice->invoice_number,
null,
$act?->pdf_path,
$act?->upd_number,
));
}
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasInvoiceItem;
use App\Models\TenantRequisites;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создание счёта на пополнение баланса (УСН, без НДС). Вызывается из HTTP под
* middleware tenant (RLS-контекст). Нумерация атомарна. PDF в приватный storage.
*/
final class InvoiceService
{
/** Наименование услуги в счёте/акте (УСН без НДС). */
public const SERVICE_NAME = 'Оплата генерации рекламных лидов';
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly PdfRenderer $pdf,
) {}
public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
{
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
if ($req === null || blank($req->inn)) {
throw new RequisitesIncompleteException('Заполните реквизиты компании, чтобы выставить счёт.');
}
// «Наш» получатель — юрлицо-оператор по флагу is_default; иначе первое.
$seller = LegalEntity::where('is_default', true)->first()
?? LegalEntity::orderBy('id')->firstOrFail();
$payerEmail = null;
if ($userId !== null) {
$email = User::query()->whereKey($userId)->value('email');
$payerEmail = is_string($email) && $email !== '' ? $email : null;
}
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
$now = Carbon::now('Europe/Moscow');
$number = $this->numbers->next((int) $seller->id, $now);
$invoice = SaasInvoice::create([
'tenant_id' => $tenantId,
'legal_entity_id' => $seller->id,
'invoice_number' => $number,
'payer_type' => $req->subject_type === 'individual' ? 'individual' : 'legal',
'payer_name' => $req->legal_name ?? $req->contact_name,
'payer_inn' => $req->inn,
'payer_kpp' => $req->kpp,
'payer_address' => $req->legal_address,
'payer_email' => $payerEmail,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $now,
'expires_at' => $now->copy()->addWeekdays(5),
]);
SaasInvoiceItem::create([
'invoice_id' => $invoice->id,
'name' => self::SERVICE_NAME,
'quantity' => 1,
'unit' => 'усл.',
'price' => $amountRub,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
]);
$path = $this->pdf->renderToStorage('pdf.invoice', [
'invoice' => $invoice,
'items' => $invoice->items()->get(),
'seller' => $seller,
], "invoices/{$invoice->id}-{$number}.pdf");
$invoice->pdf_path = $path;
$invoice->save();
return $invoice;
});
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Storage;
/**
* Рендер Blade-шаблона в PDF и сохранение в приватный storage (disk 'local').
* Возвращает относительный путь для saas_invoices.pdf_path / saas_upd_documents.pdf_path.
*/
final class PdfRenderer
{
/**
* @param array<string,mixed> $data
*/
public function renderToStorage(string $view, array $data, string $relativePath): string
{
$pdf = Pdf::loadView($view, $data)->setPaper('a4');
Storage::disk('local')->put($relativePath, $pdf->output());
return $relativePath;
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use RuntimeException;
/**
* Реквизиты компании клиента не заполнены счёт выставить нельзя.
*/
final class RequisitesIncompleteException extends RuntimeException {}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use App\Models\SalesUser;
use Illuminate\Support\Facades\Hash;
/**
* Заведение аккаунтов отдела продаж начальником (Task 7.1a).
*
* SaaS-level: sales_users без RLS. Пароль хешируется явно (Hash::make)
* в модели SalesUser каста 'hashed' нет.
*
* Работает под middleware admin-db (crm_admin_user) через DEFAULT connection.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 7.1a)
*/
class SalesAccountService
{
/**
* Создать аккаунт менеджера/начальника от имени head.
*
* $role 'manager' | 'head' (валидируется контроллером).
* base_salary_rub=0, is_active=true, created_by=$head->id.
*/
public function create(SalesUser $head, string $name, string $email, string $password, string $role): SalesUser
{
return SalesUser::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
'role' => $role,
'is_active' => true,
'base_salary_rub' => 0,
'created_by' => $head->id,
]);
}
}
@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use App\Mail\Sales\AttachmentDecidedMail;
use App\Mail\Sales\AttachmentNotFoundMail;
use App\Mail\Sales\AttachmentSubmittedMail;
use App\Models\SalesAttachmentRequest;
use App\Models\SalesClientAssignment;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Models\TenantRequisites;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Сервис заявок на привязку клиента (портал отдела продаж).
*
* Менеджер подаёт заявку по логину клиента (e-mail или ИНН).
* Сервис ищет тенанта, создаёт SalesAttachmentRequest и рассылает уведомления.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md Task 2.1
*/
final class SalesAttachmentService
{
/**
* Найти тенанта по логину: contact_email ИЛИ ИНН из tenant_requisites.
* Возвращает null если не найден.
*/
public function lookup(string $login): ?Tenant
{
/** @var Tenant|null $byEmail */
$byEmail = Tenant::query()
->whereNull('deleted_at')
->where('contact_email', $login)
->first();
if ($byEmail !== null) {
return $byEmail;
}
/** @var TenantRequisites|null $req */
$req = DB::table('tenant_requisites')
->where('inn', $login)
->first();
if ($req === null) {
return null;
}
/** @var Tenant|null $byInn */
$byInn = Tenant::query()
->whereNull('deleted_at')
->where('id', $req->tenant_id)
->first();
return $byInn;
}
/**
* Подать заявку на привязку клиента.
*
* Ветки:
* - клиент не найден status='not_found' + AttachmentNotFoundMail менеджеру
* - клиент свободен status='pending' + AttachmentSubmittedMail менеджеру + всем начальникам
* - занят другим status='pending' с comment об имени другого менеджера
* - уже у этого менеджера DomainException "Этот клиент уже ваш."
*/
public function submit(SalesUser $manager, string $login): SalesAttachmentRequest
{
$tenant = $this->lookup($login);
if ($tenant === null) {
return $this->handleNotFound($manager, $login);
}
// Проверяем существующую привязку
$existing = SalesClientAssignment::query()
->where('tenant_id', $tenant->id)
->with('salesUser')
->first();
if ($existing !== null) {
if ($existing->sales_user_id === $manager->id) {
throw new \DomainException('Этот клиент уже ваш.');
}
return $this->handleConflict($manager, $login, $tenant, $existing);
}
return $this->handleFree($manager, $login, $tenant);
}
/**
* Решение начальника по заявке. Только role='head' иначе бросить AuthorizationException.
*
* approve:
* - Снимает snapshot текущего тарифа запрашивающего менеджера.
* - Если клиент уже привязан к другому менеджеру (reassign) удаляет старую привязку.
* - Создаёт новую SalesClientAssignment с тарифным snapshot-ом.
* - Отмечает заявку 'approved'. Ставит AttachmentDecidedMail в очередь.
*
* reject:
* - Отмечает заявку 'rejected', сохраняет comment.
* - Ставит AttachmentDecidedMail в очередь.
*
* @param string $action 'approve'|'reject'
*
* @throws AuthorizationException если $head не является начальником (role != 'head')
* @throws \DomainException если заявка не в статусе 'pending'
*/
public function decide(
SalesUser $head,
int $requestId,
string $action,
?string $comment = null,
): SalesAttachmentRequest {
if (! $head->isHead()) {
throw new AuthorizationException('Только начальник может принимать решение по заявке.');
}
/** @var SalesAttachmentRequest $request */
$request = SalesAttachmentRequest::findOrFail($requestId);
if ($request->status !== 'pending') {
throw new \DomainException(
'Нельзя принять решение по заявке со статусом «'.$request->status.'».'
);
}
if ($action === 'approve') {
return $this->handleApprove($head, $request);
}
return $this->handleReject($head, $request, $comment);
}
// ── private ──────────────────────────────────────────────────────────────
private function handleNotFound(SalesUser $manager, string $login): SalesAttachmentRequest
{
/** @var SalesAttachmentRequest $request */
$request = DB::transaction(static function () use ($manager, $login): SalesAttachmentRequest {
return SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => $login,
'tenant_id' => null,
'status' => 'not_found',
]);
});
Mail::to($manager->email)->queue(new AttachmentNotFoundMail(
managerName: $manager->name,
loginInput: $login,
));
return $request;
}
private function handleFree(
SalesUser $manager,
string $login,
Tenant $tenant,
): SalesAttachmentRequest {
/** @var SalesAttachmentRequest $request */
$request = DB::transaction(static function () use ($manager, $login, $tenant): SalesAttachmentRequest {
return SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => $login,
'tenant_id' => $tenant->id,
'status' => 'pending',
]);
});
$mail = new AttachmentSubmittedMail(
managerName: $manager->name,
loginInput: $login,
tenantName: $tenant->organization_name,
conflictComment: null,
);
Mail::to($manager->email)->queue($mail);
/** @var Collection<int, SalesUser> $heads */
$heads = SalesUser::query()
->where('role', 'head')
->where('is_active', true)
->get();
foreach ($heads as $head) {
Mail::to($head->email)->queue(new AttachmentSubmittedMail(
managerName: $manager->name,
loginInput: $login,
tenantName: $tenant->organization_name,
conflictComment: null,
));
}
return $request;
}
private function handleConflict(
SalesUser $manager,
string $login,
Tenant $tenant,
SalesClientAssignment $existing,
): SalesAttachmentRequest {
$salesUser = $existing->salesUser;
$otherManagerName = $salesUser !== null ? $salesUser->name : 'другой менеджер';
$conflictComment = 'уже закреплён за: '.$otherManagerName;
/** @var SalesAttachmentRequest $request */
$request = DB::transaction(
static function () use ($manager, $login, $tenant, $conflictComment): SalesAttachmentRequest {
return SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => $login,
'tenant_id' => $tenant->id,
'status' => 'pending',
'comment' => $conflictComment,
]);
}
);
$mail = new AttachmentSubmittedMail(
managerName: $manager->name,
loginInput: $login,
tenantName: $tenant->organization_name,
conflictComment: $conflictComment,
);
Mail::to($manager->email)->queue($mail);
/** @var Collection<int, SalesUser> $heads */
$heads = SalesUser::query()
->where('role', 'head')
->where('is_active', true)
->get();
foreach ($heads as $head) {
Mail::to($head->email)->queue(new AttachmentSubmittedMail(
managerName: $manager->name,
loginInput: $login,
tenantName: $tenant->organization_name,
conflictComment: $conflictComment,
));
}
return $request;
}
private function handleApprove(SalesUser $head, SalesAttachmentRequest $request): SalesAttachmentRequest
{
/** @var SalesUser $manager */
$manager = SalesUser::findOrFail($request->sales_user_id);
// Snapshot тарифа менеджера на момент одобрения
$tariffId = $manager->current_tariff_id;
$tariff = $tariffId !== null ? SalesTariff::find($tariffId) : null;
$tenantId = $request->tenant_id;
DB::transaction(static function () use ($head, $request, $manager, $tariff, $tariffId, $tenantId): void {
// Reassignment: если клиент уже привязан к другому менеджеру — удаляем старую привязку
if ($tenantId !== null) {
SalesClientAssignment::where('tenant_id', $tenantId)->delete();
}
// Создаём новую привязку со snapshot тарифа
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenantId,
'tariff_id' => $tariffId,
'tariff_kind' => $tariff !== null ? $tariff->kind : null,
'tariff_params' => $tariff !== null ? $tariff->params : [],
'assigned_at' => now(),
]);
// Помечаем заявку как одобренную
$request->status = 'approved';
$request->decided_by = $head->id;
$request->decided_at = now();
$request->save();
});
$request->refresh();
// Уведомляем менеджера
Mail::to($manager->email)->queue(new AttachmentDecidedMail(
managerName: $manager->name,
loginInput: $request->login_input,
tenantName: $request->tenant?->organization_name,
action: 'approved',
tariffName: $tariff?->name,
comment: null,
));
return $request;
}
private function handleReject(
SalesUser $head,
SalesAttachmentRequest $request,
?string $comment,
): SalesAttachmentRequest {
/** @var SalesUser $manager */
$manager = SalesUser::findOrFail($request->sales_user_id);
DB::transaction(static function () use ($head, $request, $comment): void {
$request->status = 'rejected';
$request->decided_by = $head->id;
$request->decided_at = now();
$request->comment = $comment;
$request->save();
});
$request->refresh();
// Уведомляем менеджера
Mail::to($manager->email)->queue(new AttachmentDecidedMail(
managerName: $manager->name,
loginInput: $request->login_input,
tenantName: $request->tenant?->organization_name,
action: 'rejected',
tariffName: null,
comment: $comment,
));
return $request;
}
}
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use Carbon\CarbonImmutable;
/**
* Расчёт дохода менеджера отдела продаж (модель 03.07.2026: ДВА вида тарифа).
*
* Денежное ядро. Комиссия по клиентам считается ВСЕГДА по snapshot тарифа привязки
* (SalesClientAssignment.tariff_kind / tariff_params) не по live-тарифу менеджера
* (правило В12/Р16: смена тарифа менеджера не переписывает историю закреплённых клиентов).
*
* Виды тарифа:
* daily_salary суточный оклад. Уровень МЕНЕДЖЕРА (не клиента): forAssignment = 0.
* Оклад начисляет forManager, если ТЕКУЩИЙ тариф менеджера = daily_salary:
* ставка /день (base_salary_rub) × отработанные дни периода СО ДНЯ ПРИЁМА
* (created_at). Клиенты на оклад не влияют.
* topup_step процент от пополнений с ПОРОГОМ + РАЗОВЫМ БОНУСОМ (по каждому клиенту):
* пока накопленные пополнения < порога (params.threshold) 0;
* при ПЕРВОМ достижении порога разовый бонус (params.reward), один раз
* за клиента (в периоде, где накопленные пополнения пересекли порог);
* с суммы пополнений СВЕРХ порога процент по ступеням срока (tenure):
* params.periods[] = {from, to, rate}. Первые «порог» рублей процентом
* не облагаются (их закрыл бонус).
*
* Округление: forManager / perClient округляют финал до 2 знаков. forAssignment НЕ округляет
* округляет вызывающий.
*
* Использует DEFAULT connection через SalesMetricsService не хардкодит имя подключения
* (вызывается под middleware admin-db, как и SalesMetricsService).
*/
class SalesEarningsService
{
public function __construct(
private SalesMetricsService $metrics,
private SalesPeriodResolver $resolver,
) {}
/**
* Комиссия по одному закреплённому клиенту за период по снимку тарифа привязки.
*
* НЕ округляет (округляет вызывающий: forManager / perClient / внешний код).
* daily_salary и любые прочие/устаревшие виды 0 (оклад начисляется на уровне менеджера).
*/
public function forAssignment(SalesClientAssignment $a, SalesPeriodRange $range): float
{
return match ($a->tariff_kind) {
'topup_step' => $this->topupStepEarning($a, $range),
default => 0.0,
};
}
/**
* Полный доход менеджера за период: суточный оклад (если текущий тариф = daily_salary)
* + Σ комиссий по всем закреплённым клиентам (по снимку тарифа каждого).
*/
public function forManager(SalesUser $u, SalesPeriodRange $range): float
{
$commission = 0.0;
foreach ($u->assignments as $assignment) {
$commission += $this->forAssignment($assignment, $range);
}
$salary = 0.0;
$tariff = $u->currentTariff;
if ($tariff !== null && $tariff->kind === 'daily_salary') {
$salary = $this->dailySalary($u, $range);
}
return round($salary + $commission, 2);
}
/**
* Детализация дохода по каждому закреплённому клиенту за период.
*
* @return list<array{tenant_id: int, tariff_kind: string|null, topups_rub: float, earned_rub: float}>
*/
public function perClient(SalesUser $u, SalesPeriodRange $range): array
{
$rows = [];
foreach ($u->assignments as $assignment) {
$rows[] = [
'tenant_id' => $assignment->tenant_id,
'tariff_kind' => $assignment->tariff_kind,
'topups_rub' => $this->metrics->topupsRub($assignment->tenant_id, $range),
'earned_rub' => round($this->forAssignment($assignment, $range), 2),
];
}
return $rows;
}
/**
* Оценка дохода «если бы ВСЕ клиенты менеджера были на его ТЕКУЩЕМ тарифе за период».
*
* Информационная цифра для экрана «Тарифы менеджеров». По факту доход идёт по снимку
* каждого клиента (forAssignment) здесь же считаем гипотетику текущего тарифа.
* daily_salary оценка = суточный оклад (клиенты не влияют);
* topup_step Σ forAssignment по каждому клиенту со снимком = текущий тариф менеджера;
* нет текущего тарифа 0.
*/
public function estimatedForCurrentTariff(SalesUser $manager, SalesPeriodRange $range): float
{
$tariff = $manager->currentTariff;
if ($tariff === null) {
return 0.0;
}
if ($tariff->kind === 'daily_salary') {
return round($this->dailySalary($manager, $range), 2);
}
$bonus = 0.0;
foreach ($manager->assignments as $assignment) {
$probe = new SalesClientAssignment;
$probe->tenant_id = (int) $assignment->tenant_id;
$probe->tariff_kind = $tariff->kind;
$probe->tariff_params = $tariff->params;
$probe->assigned_at = $assignment->assigned_at;
$bonus += $this->forAssignment($probe, $range);
}
return round($bonus, 2);
}
/**
* Суточный оклад менеджера за период: ставка /день × число отработанных дней.
*
* Дни считаются СО ДНЯ ПРИЁМА (created_at): [max(range.start, приём) .. range.end]
* включительно. Ставка /день = base_salary_rub.
*/
private function dailySalary(SalesUser $u, SalesPeriodRange $range): float
{
$rate = (float) $u->base_salary_rub;
if ($rate <= 0.0) {
return 0.0;
}
return $rate * $this->workedDays($u, $range);
}
/**
* Число отработанных календарных дней менеджера в периоде (со дня приёма, включительно).
*/
private function workedDays(SalesUser $u, SalesPeriodRange $range): int
{
$hire = CarbonImmutable::parse($u->created_at)
->setTimezone('Europe/Moscow')
->startOfDay();
$start = $range->start->startOfDay();
if ($hire->greaterThan($start)) {
$start = $hire;
}
$end = $range->end->startOfDay();
if ($end->lessThan($start)) {
return 0;
}
return (int) $start->diffInDays($end) + 1;
}
/**
* topup_step: разовый бонус при пересечении порога + процент со суммы СВЕРХ порога.
*
* Идём по месяцам периода, ведём накопленную сумму пополнений (cum). Начинаем с
* накопленных пополнений ДО начала первого месяца (cumulativeTopupsAsOf). В каждом
* месяце: «сверх-порога» = часть месячных пополнений выше порога (по накопленной сумме)
* × ставка ступени по сроку (tenure). Бонус если накопленная сумма ПЕРЕСЕКЛА порог
* в этом периоде (был < порога, стал порога) один раз.
*/
private function topupStepEarning(SalesClientAssignment $a, SalesPeriodRange $range): float
{
/** @var array<string,mixed> $params */
$params = $a->tariff_params ?? [];
$threshold = (float) ($params['threshold'] ?? 0);
$reward = (float) ($params['reward'] ?? 0);
/** @var list<array{from?: int, to?: int, rate?: float|int}> $periods */
$periods = $params['periods'] ?? [];
$assignedAt = CarbonImmutable::parse($a->assigned_at)->startOfMonth();
// Накопленные пополнения до начала первого месяца периода.
$cum = $this->metrics->cumulativeTopupsAsOf($a->tenant_id, $range->start->startOfMonth());
$percent = 0.0;
$crossed = false;
foreach ($this->resolver->monthsIn($range) as $monthStart) {
$monthRange = new SalesPeriodRange(
$monthStart->startOfDay(),
$monthStart->endOfMonth()->setTime(23, 59, 59),
);
$monthTopups = $this->metrics->topupsRub($a->tenant_id, $monthRange);
$startCum = $cum;
$endCum = $cum + $monthTopups;
// Разовый бонус: порог пересечён именно в этом месяце.
if ($threshold > 0.0 && $startCum < $threshold && $endCum >= $threshold) {
$crossed = true;
}
// Часть месячных пополнений выше порога (по накопленной сумме).
$above = max(0.0, $endCum - max($startCum, $threshold));
if ($above > 0.0) {
$tenure = (int) $assignedAt->diffInMonths($monthStart) + 1;
$rate = $this->rateForTenure($periods, $tenure);
$percent += $above * $rate / 100;
}
$cum = $endCum;
}
return ($crossed ? $reward : 0.0) + $percent;
}
/**
* Ставка (в процентах) для срока tenure по таблице ступеней. Вне таблицы 0.
*
* @param list<array{from?: int, to?: int, rate?: float|int}> $periods
*/
private function rateForTenure(array $periods, int $tenure): float
{
foreach ($periods as $step) {
$from = (int) ($step['from'] ?? 0);
$to = (int) ($step['to'] ?? 0);
if ($tenure >= $from && $tenure <= $to) {
return (float) ($step['rate'] ?? 0);
}
}
return 0.0;
}
}
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\RunwayCalculator;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Метрики продаж для портала отдела продаж (Task 1.2).
*
* Читает существующие таблицы: deals, lead_charges, balance_transactions, tenants, projects.
*
* ВАЖНО денежные правила:
* - oborotRub: суммируем INTEGER kopecks (SUM(price_per_lead_kopecks)), делим на 100 в конце.
* Float-суммирование ЗАПРЕЩЕНО (ведёт к накопительной ошибке при большом числе строк).
* - topupsRub / cumulativeTopupsRub: DECIMAL(12,2) amount_rub суммируем через SQL SUM,
* возвращаем как float.
*
* ВАЖНО граница периода (half-open interval):
* - Запрос: >= range.start AND < nextDayAfterEnd (start-of-day AFTER last day).
* - НЕ используем <= range.end (23:59:59 без микросекунд теряем [23:59:59.001..полночь)).
* - range.end startOfDay()->addDay() = полночь следующего дня (UTC).
*
* Счётчик leadsDelivered соответствует DashboardController: deleted_at IS NULL, is_test=false.
* Дубли (duplicate_of_id NOT NULL) НЕ исключаются как в существующих "delivered" counts.
*
* runwayDays: реиспользует RunwayCalculator + BalanceToLeadsConverter единый источник истины
* для прогноза runway (совпадает с клиентским кабинетом и дашбордом, как требует RunwayCalculator
* docblock F3 17.06.2026).
*
* Вызывается из /api/sales-зоны под middleware admin-db (pgsql_admin / crm_admin_user).
* Сервис использует DEFAULT connection не хардкодит имя подключения.
*/
class SalesMetricsService
{
/**
* Число доставленных лидов тенанта за период.
*
* Определение «delivered»: deals с received_at в [range.start, nextDayAfterEnd),
* is_test=false, deleted_at IS NULL. Дубли (duplicate_of_id NOT NULL) включаются
* исторически DashboardController их не исключает (поле duplicate_of_id не фильтруется).
*/
public function leadsDelivered(int $tenantId, SalesPeriodRange $range): int
{
$nextDay = $range->end->startOfDay()->addDay();
return (int) DB::table('deals')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_test', false)
->where('received_at', '>=', $range->start)
->where('received_at', '<', $nextDay)
->count();
}
/**
* Оборот тенанта за период в рублях.
*
* SUM(price_per_lead_kopecks) по lead_charges в периоде, делённый на 100.
* Суммируем INTEGER kopecks не float, исключая накопительную ошибку.
*/
public function oborotRub(int $tenantId, SalesPeriodRange $range): float
{
$nextDay = $range->end->startOfDay()->addDay();
$sumKopecks = (int) DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charged_at', '>=', $range->start)
->where('charged_at', '<', $nextDay)
->sum('price_per_lead_kopecks');
return $sumKopecks / 100;
}
/**
* Сумма пополнений тенанта за период в рублях.
*
* SUM(amount_rub) по balance_transactions где type='topup' в периоде.
*/
public function topupsRub(int $tenantId, SalesPeriodRange $range): float
{
$nextDay = $range->end->startOfDay()->addDay();
return (float) DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'topup')
->where('created_at', '>=', $range->start)
->where('created_at', '<', $nextDay)
->sum('amount_rub');
}
/**
* Накопленные пополнения тенанта за всё время (без ограничения периода).
*
* Используется для расчёта порога фиксированной выплаты.
*/
public function cumulativeTopupsRub(int $tenantId): float
{
return (float) DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'topup')
->sum('amount_rub');
}
/**
* Накопленные пополнения тенанта ДО заданной границы (created_at < $boundary).
*
* Нужно тарифу topup_step: определить, сколько пополнений клиента накоплено к
* началу периода от этого зависит порог (разовый бонус + процент со сверх-порога).
* Полуоткрытая граница: строго МЕНЬШЕ $boundary.
*/
public function cumulativeTopupsAsOf(int $tenantId, CarbonInterface $boundary): float
{
return (float) DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'topup')
->where('created_at', '<', $boundary)
->sum('amount_rub');
}
/**
* Прогноз «запас в днях» для тенанта.
*
* Реиспользует единственный источник истины: BalanceToLeadsConverter
* (рублёвый баланс число лидов по тарифной сетке) + RunwayCalculator
* (лиды / дневной_заказ_активных_проектов).
*
* Результат совпадает с клиентским кабинетом (BillingController::wallet)
* и дашбордом (DashboardController::summary) F3 17.06.2026.
*
* null нет активных проектов (нечего заказывать).
* 0 баланс исчерпан (affordable_leads = 0).
* N floor(affordable_leads / daily_order).
*/
public function runwayDays(int $tenantId): ?int
{
$activeTiers = app(PricingTierRepository::class)
->activeAt(Carbon::now('Europe/Moscow'));
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return null;
}
$conversion = app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$affordableLeads = (int) $conversion['leads'];
return app(RunwayCalculator::class)->daysLeft($tenantId, $affordableLeads);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use App\Mail\Sales\SalesPayoutRecordedMail;
use App\Models\SalesPayout;
use App\Models\SalesUser;
use Illuminate\Support\Facades\Mail;
/**
* Проведение и учёт выплат менеджерам отдела продаж (Task 4.1).
*
* Денежное ядро. Журнал sales_payouts APPEND-ONLY: запись создаётся один раз,
* UPDATE/DELETE запрещены DB-триггером sales_payouts_no_mutate. Здесь никакой
* мутации существующих выплат нет и быть не может.
*
* remaining() «остаток к выплате» = начислено (SalesEarningsService::forManager)
* за период уже выплачено В ЭТОМ ПЕРИОДЕ (по дате paid_on).
*
* Границы периода по выплатам считаются по ДАТЕ paid_on (тип date), не по времени:
* paid_on >= range.start->toDateString() AND paid_on <= range.end->toDateString()
* (закрытый интервал по календарным дням paid_on без времени).
*
* Работает через DEFAULT connection (под middleware admin-db), как и остальные
* sales-сервисы.
*/
class SalesPayoutService
{
public function __construct(
private readonly SalesEarningsService $earnings,
) {}
/**
* Провести выплату менеджеру: создать append-only запись + письмо менеджеру.
*/
public function record(
SalesUser $head,
int $managerId,
float|string $amount,
string $paidOn,
?string $comment,
): SalesPayout {
$payout = SalesPayout::create([
'sales_user_id' => $managerId,
'amount_rub' => (string) $amount,
'paid_on' => $paidOn,
'comment' => $comment,
'created_by' => $head->id,
]);
// $timestamps=false → created_at заполняется DB-дефолтом, но не хидрируется
// в свежую модель. Подтягиваем из БД, чтобы row() отдал created_at.
$payout->refresh();
/** @var SalesUser $manager */
$manager = SalesUser::query()->findOrFail($managerId);
Mail::to($manager->email)->send(new SalesPayoutRecordedMail($payout, $manager));
return $payout;
}
/**
* Начислено / выплачено / остаток для менеджера за период.
*
* accrued_rub начислено за период (SalesEarningsService::forManager).
* paid_period_rub Σ выплат с paid_on [range.start .. range.end] (по дате).
* paid_all_time_rub Σ всех выплат менеджера за всё время.
* remaining_rub accrued_rub paid_period_rub (round 2), может быть < 0.
*
* @return array{accrued_rub: float, paid_period_rub: float, paid_all_time_rub: float, remaining_rub: float}
*/
public function remaining(SalesUser $manager, SalesPeriodRange $range): array
{
$accrued = $this->earnings->forManager($manager, $range);
$paidPeriod = (float) SalesPayout::query()
->where('sales_user_id', $manager->id)
->where('paid_on', '>=', $range->start->toDateString())
->where('paid_on', '<=', $range->end->toDateString())
->sum('amount_rub');
$paidAllTime = (float) SalesPayout::query()
->where('sales_user_id', $manager->id)
->sum('amount_rub');
return [
'accrued_rub' => round($accrued, 2),
'paid_period_rub' => round($paidPeriod, 2),
'paid_all_time_rub' => round($paidAllTime, 2),
'remaining_rub' => round($accrued - $paidPeriod, 2),
];
}
/**
* Журнал выплат. $ownOnly (менеджер) только свои; иначе (head) все.
* Порядок newest first: paid_on desc, id desc.
*
* @return list<array{id: int, sales_user_id: int, manager_name: string|null, amount_rub: float, paid_on: string, comment: string|null, created_at: string, creator_name: string|null}>
*/
public function journal(SalesUser $viewer, bool $ownOnly): array
{
$query = SalesPayout::query()
->with(['salesUser', 'creator'])
->orderByDesc('paid_on')
->orderByDesc('id');
if ($ownOnly) {
$query->where('sales_user_id', $viewer->id);
}
return $query->get()
->map(fn (SalesPayout $p): array => $this->row($p))
->all();
}
/**
* Единый формат строки выплаты (для journal и ответа store).
*
* @return array{id: int, sales_user_id: int, manager_name: string|null, amount_rub: float, paid_on: string, comment: string|null, created_at: string, creator_name: string|null}
*/
public function row(SalesPayout $payout): array
{
return [
'id' => $payout->id,
'sales_user_id' => $payout->sales_user_id,
'manager_name' => $payout->salesUser?->name,
'amount_rub' => (float) $payout->amount_rub,
'paid_on' => $payout->paid_on->format('Y-m-d'),
'comment' => $payout->comment,
'created_at' => $payout->created_at->toIso8601String(),
'creator_name' => $payout->creator?->name,
];
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use Carbon\CarbonImmutable;
/**
* Конкретный диапазон дат для периода продаж.
*
* start начало диапазона (00:00:00 МСК, включительно).
* end конец диапазона (23:59:59 МСК, включительно последнего дня).
* Оба значения в часовом поясе Europe/Moscow.
*/
final readonly class SalesPeriodRange
{
public function __construct(
public CarbonImmutable $start,
public CarbonImmutable $end,
) {}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
/**
* Преобразует выбор периода с фронтенда в конкретный диапазон дат МСК.
*
* Поддерживаемые kind:
* 'this' текущий месяц целиком.
* 'prev' предыдущий месяц целиком.
* 'prev2' месяц перед предыдущим целиком.
* 'custom' явный диапазон from..to (YYYY-MM-DD МСК).
*
* Неизвестный kind по умолчанию трактуется как 'this'.
* Все вычисления в Europe/Moscow через CarbonImmutable.
* "Now" берётся из CarbonImmutable::now('Europe/Moscow'),
* поэтому тесты могут замораживать время через CarbonImmutable::setTestNow().
*/
final class SalesPeriodResolver
{
private const TZ = 'Europe/Moscow';
/**
* @param array{kind?: string, from?: string|null, to?: string|null} $period
*
* @throws InvalidArgumentException для kind=custom при неверных/отсутствующих датах
*/
public function resolve(array $period): SalesPeriodRange
{
$kind = $period['kind'] ?? 'this';
return match ($kind) {
'prev' => $this->monthRange(-1),
'prev2' => $this->monthRange(-2),
'custom' => $this->customRange($period),
default => $this->monthRange(0), // 'this' и любой неизвестный kind
};
}
/**
* Список первых чисел каждого месяца (00:00 МСК), попадающего в диапазон.
*
* Например, диапазон 10 марта 20 мая вернёт [1 марта, 1 апреля, 1 мая].
*
* @return list<CarbonImmutable>
*/
public function monthsIn(SalesPeriodRange $range): array
{
$months = [];
$cursor = $range->start->startOfMonth();
while ($cursor->lte($range->end)) {
$months[] = $cursor;
$cursor = $cursor->addMonth();
}
return $months;
}
// ─── private ───────────────────────────────────────────────────────────────
/**
* Полный диапазон месяца, смещённого на $offset от текущего.
*
* $offset = 0 текущий месяц
* $offset = -1 предыдущий месяц
* $offset = -2 позапрошлый месяц
*/
private function monthRange(int $offset): SalesPeriodRange
{
$now = CarbonImmutable::now(self::TZ);
$base = $offset === 0
? $now
: $now->addMonths($offset);
$start = $base->startOfMonth()->startOfDay();
$end = $base->endOfMonth()->setTime(23, 59, 59);
return new SalesPeriodRange($start, $end);
}
/**
* @param array{kind?: string, from?: string|null, to?: string|null} $period
*/
private function customRange(array $period): SalesPeriodRange
{
if (empty($period['from'])) {
throw new InvalidArgumentException(
'Для произвольного периода необходимо указать дату «от» (from).',
);
}
if (empty($period['to'])) {
throw new InvalidArgumentException(
'Для произвольного периода необходимо указать дату «до» (to).',
);
}
$start = CarbonImmutable::parse($period['from'], self::TZ)->startOfDay();
$end = CarbonImmutable::parse($period['to'], self::TZ)->setTime(23, 59, 59);
if ($start->gt($end)) {
throw new InvalidArgumentException(
'Дата начала периода не может быть позже даты окончания.',
);
}
return new SalesPeriodRange($start, $end);
}
}
@@ -7,13 +7,11 @@ namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -31,7 +29,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
@@ -81,7 +78,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
@@ -108,7 +104,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
@@ -147,24 +142,9 @@ final class FailoverProjectChannel implements SupplierProjectChannel
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use App\Models\Project;
use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* «Задуманный заказ» на targetDate та же группировка и формула, что у робота
* SyncSupplierProjectsJob (DRY через SupplierProjectGrouping + SupplierQuotaAllocator).
*
* Возвращает ['intended' => list<row>], где row = ['signal_type','identifier','platform','limit'],
* только доли ≥1 (как distributeForPlatform).
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2
*/
final class SupplierOrderPlan
{
/**
* @param Collection<int, Project> $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions)
* @return array{intended: list<array{signal_type:string,identifier:string,platform:string,limit:int}>}
*/
public static function build(Collection $eligibleProjects, Carbon $targetDate): array
{
$targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
// Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob.
/** @var array<string, array{signal_type:string, identifier:string, platforms:list<string>, limits:list<int>}> $groups */
$groups = [];
foreach ($eligibleProjects as $project) {
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
if ($platforms === []) {
continue;
}
// eligible-today (маска дня на targetDate).
if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) {
continue;
}
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
$key = $project->signal_type.'|'.$identifier;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => (string) $project->signal_type,
'identifier' => $identifier,
'platforms' => $platforms,
'limits' => [],
];
}
$groups[$key]['limits'][] = (int) $project->daily_limit_target;
}
$intended = [];
foreach ($groups as $group) {
$order = SupplierQuotaAllocator::computeOrder($group['limits']);
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']);
foreach ($shares as $platform => $limit) {
$intended[] = [
'signal_type' => $group['signal_type'],
'identifier' => $group['identifier'],
'platform' => $platform,
'limit' => $limit,
];
}
}
return ['intended' => $intended];
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
/**
* Чистая сверка «задуманный заказ» vs «живой кабинет поставщика».
*
* Возвращает список расхождений; каждый ['kind','signal_type','identifier','platform','expected','actual'].
* kind missing | limit_drift | should_be_off | should_be_on | orphan_extra.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4.2
*/
final class SupplierOrderVerifier
{
/**
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int}> $intended
* @param list<array{signal_type:string,identifier:string,platform:string}> $shouldBeOff
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}> $live
* @return list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}>
*/
public static function diff(array $intended, array $shouldBeOff, array $live): array
{
$key = static fn (array $r): string => $r['signal_type'].'|'.$r['identifier'].'|'.$r['platform'];
$liveByKey = [];
foreach ($live as $row) {
$liveByKey[$key($row)] = $row;
}
$mismatches = [];
$accountedLive = [];
// 1. Активные по формуле.
foreach ($intended as $row) {
$k = $key($row);
$accountedLive[$k] = true;
$liveRow = $liveByKey[$k] ?? null;
if ($liveRow === null) {
$mismatches[] = self::m('missing', $row, $row['limit'], null);
continue;
}
if ($liveRow['on'] !== true) {
$mismatches[] = self::m('should_be_on', $row, true, false);
continue;
}
if ((int) $liveRow['limit'] !== (int) $row['limit']) {
$mismatches[] = self::m('limit_drift', $row, (int) $row['limit'], (int) $liveRow['limit']);
}
}
// 2. Должны быть выключены.
foreach ($shouldBeOff as $row) {
$k = $key($row);
$accountedLive[$k] = true;
$liveRow = $liveByKey[$k] ?? null;
if ($liveRow !== null && $liveRow['on'] === true) {
$mismatches[] = self::m('should_be_off', $row, false, true);
}
}
// 3. Лишние наши строки у поставщика, которых нет ни в intended, ни в shouldBeOff.
foreach ($live as $row) {
if (! isset($accountedLive[$key($row)]) && $row['on'] === true) {
$mismatches[] = self::m('orphan_extra', $row, null, (int) $row['limit']);
}
}
return $mismatches;
}
/**
* @param array{signal_type:string,identifier:string,platform:string} $row
* @return array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}
*/
private static function m(string $kind, array $row, int|string|bool|null $expected, int|string|bool|null $actual): array
{
return [
'kind' => $kind,
'signal_type' => $row['signal_type'],
'identifier' => $row['identifier'],
'platform' => $row['platform'],
'expected' => $expected,
'actual' => $actual,
];
}
}
@@ -69,6 +69,81 @@ class SupplierPortalClient
return is_array($projects) ? array_values($projects) : [];
}
/** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */
private const DELIVERED_PAGE_SIZE = 50;
/** Backstop от бесконечной пагинации (аномалия портала). */
private const DELIVERED_MAX_PAGES = 500;
/**
* Журнал ОТДАННОГО («Мои сделки») за окно [from, to] гарантированный канал доставки.
*
* В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров
* (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально
* ОТГРУЖЕННЫЕ поставщиком лиды каждый с настоящим `vid` (= checkbox value / view?id).
* Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение
* количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15,
* но все 15 пул) ловится, чего потолок по `crms_cnt` не умеет.
*
* GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N server-rendered HTML,
* 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк.
* Verified live 2026-07-09: строка = `name="visit-checbox" value="<vid>"` +
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
*
* @return array<int, array{vid: int, phone: string, project: string}> ключ = vid (дедуп)
*/
public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array
{
$byVid = [];
$page = 1;
do {
$response = $this->request('GET', '/admin/visit/index-visit', [
'visit' => 'rt',
'date_from' => $from->format('d.m.Y'),
'date_to' => $to->format('d.m.Y'),
'page' => $page,
]);
$rows = $this->parseDeliveredRows($response->body());
foreach ($rows as $row) {
$byVid[$row['vid']] = $row;
}
$page++;
} while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES);
return $byVid;
}
/**
* Парсит строки таблицы «Мои сделки» [vid, phone, project].
* Строки без vid (шапка/служебные) или без телефона пропускаются.
*
* @return list<array{vid: int, phone: string, project: string}>
*/
private function parseDeliveredRows(string $html): array
{
$out = [];
foreach (preg_split('/<tr[ >]/', $html) ?: [] as $row) {
if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) {
continue;
}
if (preg_match('/(7\d{10})/', $row, $phone) !== 1) {
continue;
}
$project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : '';
$out[] = [
'vid' => (int) $vid[1],
'phone' => $phone[1],
'project' => $project,
];
}
return $out;
}
public function saveProject(SupplierProjectDto $dto): int
{
$response = $this->request(
@@ -107,8 +107,12 @@ final class SupplierQuotaAllocator
* Портал НЕ делит каждый B-проект набирает до своего лимита независимо; одинаковый
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
*
* Площадки с долей 0 ОПУСКАЮТСЯ: новый кабинет crm.lead.store отклоняет `limit=0`
* («Введите limit!», verified live 2026-07-01). Напр. заказ 1 на 3 площадки только B1
* получает 1, B2/B3 не отправляются. Сумма ненулевых долей по-прежнему == order.
*
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
* @return array<string, int> [platform => лимит этой площадки]
* @return array<string, int> [platform => лимит этой площадки], только доли 1
*/
public static function distributeForPlatform(int $order, array $platforms): array
{
@@ -124,7 +128,10 @@ final class SupplierQuotaAllocator
$shares = [];
$i = 0;
foreach ($platforms as $platform) {
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
$share = $base + ($i < $remainder ? 1 : 0);
if ($share >= 1) {
$shares[$platform] = $share;
}
$i++;
}
+2
View File
@@ -2,6 +2,7 @@
use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\EnsureSalesUser;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SetTenantContext;
use App\Http\Middleware\UseAdminConnection;
@@ -29,6 +30,7 @@ return Application::configure(basePath: dirname(__DIR__))
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
'admin-db' => UseAdminConnection::class,
'sales-portal' => EnsureSalesUser::class,
'apikey' => ApiKeyAuth::class,
]);
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^13.7",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
+523 -144
View File
@@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "10306f01cb35d564d5004d2202f0c7b3",
"content-hash": "da84c833d162bd54a2eff0f338eead8a",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",
@@ -456,6 +533,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -2357,6 +2589,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4017,6 +4316,86 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{
"name": "symfony/clock",
"version": "v7.4.8",
@@ -6606,6 +6985,149 @@
],
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@@ -15471,149 +15993,6 @@
},
"time": "2026-02-17T17:25:14+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",
+14
View File
@@ -1,5 +1,6 @@
<?php
use App\Models\SalesUser;
use App\Models\User;
return [
@@ -49,6 +50,13 @@ return [
'impersonation' => [
'driver' => 'impersonation',
],
// Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users.
// Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins.
'sales' => [
'driver' => 'sanctum',
'provider' => 'sales_users',
],
],
/*
@@ -78,6 +86,12 @@ return [
// 'driver' => 'database',
// 'table' => 'users',
// ],
// Провайдер для guard «sales» (портал отдела продаж, Task 0.3).
'sales_users' => [
'driver' => 'eloquent',
'model' => SalesUser::class,
],
],
/*
+301
View File
@@ -0,0 +1,301 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| Set some default values. It is possible to add all defines that can be set
| in dompdf_config.inc.php. You can also override the entire config file.
|
*/
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'public_path' => null, // Override the public path if needed
/*
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show and £.
*/
'convert_entities' => true,
'options' => [
/**
* The location of the DOMPDF font directory
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
* *Please note the trailing slash.*
*
* Notes regarding fonts:
* Additional .afm font metrics can be added by executing load_font.php from command line.
*
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
* increase file size unless font subsetting is enabled. Before embedding a font please
* review your rights under the font license.
*
* Any font specification in the source HTML is translated to the closest font available
* in the font directory.
*
* The pdf standard "Base 14 fonts" are:
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts'),
/**
* The location of a temporary directory.
*
* The directory specified must be writeable by the webserver process.
* The temporary directory is required to download remote images and when
* using the PDFLib back end.
*/
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
* subdirectory of this directory. DO NOT set it to '/' since this could
* allow an attacker to use dompdf to read any files on the server. This
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
/**
* Protocol whitelist
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* @var array
*/
'allowed_protocols' => [
'data://' => ['rules' => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* Operational artifact (log files, temporary files) path validation
*/
'artifactPathValidation' => null,
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
*/
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files.
* {@link * Canvas_Factory} ultimately determines which rendering class to
* instantiate based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
* capabilities for dompdf, however additional features (e.g. object,
* image and font support, etc.) differ between backends. Please see
* {@link PDFLib_Adapter} for more information on the PDFLib backend
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
* on CPDF. Also see the documentation for each backend at the links
* below.
*
* The GD rendering backend is a little different than PDFLib and
* CPDF. Several features of CPDF and PDFLib are not supported or do
* not make any sense when creating image files. For example,
* multiple pages are not supported, nor are PDF 'objects'. Have a
* look at {@link GD_Adapter} for more information. GD support is
* experimental, so use it at your own risk.
*
* @link http://www.pdflib.com
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
'pdf_backend' => 'CPDF',
/**
* html target media view which should be rendered into pdf.
* List of types and parsing rules for future extensions:
* http://www.w3.org/TR/REC-html40/types.html
* screen, tty, tv, projection, handheld, print, braille, aural, all
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
* Note, even though the generated pdf file is intended for print output,
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
'default_media_type' => 'screen',
/**
* The default paper size.
*
* North America standard is "letter"; other countries generally "a4"
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
'default_font' => 'dejavu sans',
/**
* Image DPI setting
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
* image's width & height style attributes (i.e. if the image's native
* width is 600 pixels and you specify the image's width as 72 points,
* the image will have a DPI of 600 in the rendered PDF. The DPI of
* background images can not be overridden and is controlled entirely
* via this parameter.
*
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
* If a size in html is given as px (or without unit as image size),
* this tells the corresponding size in pt.
* This adjusts the relative sizes to be similar to the rendering of the
* html page in a reference browser.
*
* In pdf, always 1 pt = 1/72 inch
*
* Rendering resolution of various browsers in px per inch:
* Windows Firefox and Internet Explorer:
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
* Linux Firefox:
* about:config *resolution: Default:96
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
*
* Take care about extra font/image zoom factor of browser.
*
* In images, <img> size in pixel attribute, img css style, are overriding
* the real image dimension in px for rendering.
*
* @var int
*/
'dpi' => 96,
/**
* Enable embedded PHP
*
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
* within <script type="text/php"> ... </script> tags.
*
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
* is a security risk.
* Embedded scripts are run with the same level of system access available to dompdf.
* Set this option to false (recommended) if you wish to process untrusted documents.
* This setting may increase the risk of system exploit.
* Do not change this settings without understanding the consequences.
* Additional documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_php' => false,
/**
* Enable inline JavaScript
*
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
* not browser-based JavaScript executed by Dompdf.
*
* @var bool
*/
'enable_javascript' => true,
/**
* Enable remote file access
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
*
* ==== IMPORTANT ====
* This can be a security risk, in particular in combination with isPhpEnabled and
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
* This allows anonymous users to download legally doubtful internet content which on
* tracing back appears to being downloaded by your server, or allows malicious php code
* in remote html pages to be executed by your server with your account privileges.
*
* This setting may increase the risk of system exploit. Do not change
* this settings without understanding the consequences. Additional
* documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_remote' => false,
/**
* List of allowed remote hosts
*
* Each value of the array must be a valid hostname.
*
* This will be used to filter which resources can be loaded in combination with
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
*
* Leave to NULL to allow any remote host.
*
* @var array|null
*/
'allowed_remote_hosts' => null,
/**
* A ratio applied to the fonts height to be more like browsers' line height
*/
'font_height_ratio' => 1.1,
/**
* Use the HTML5 Lib parser
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5_parser' => true,
],
];
+6
View File
@@ -103,6 +103,12 @@ return [
'widget_id' => env('JIVO_WIDGET_ID'),
],
// Яндекс.Метрика портала (lk.liderra.ru). Пусто = Метрика не грузится.
// Вебвизор пишет только кабинет клиента (см. resources/js/plugins/metrika.ts + router).
'metrika' => [
'counter_id' => env('METRIKA_COUNTER_ID'),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Портал отдела продаж 5 системных таблиц (SaaS-level, без RLS).
*
* Таблицы:
* - sales_tariffs каталог тарифных экземпляров (топап/процент/фикс).
* - sales_users аккаунты менеджеров и руководителей отдела продаж.
* - sales_client_assignments привязка «один менеджер на клиента» (snapshot тарифа).
* - sales_attachment_requests заявки на привязку клиента.
* - sales_payouts append-only журнал выплат (UPDATE/DELETE запрещены триггером).
*
* Права: выданы crm_admin_user (admin-db connection, которым работает портал продаж).
* Фильтрация по владельцу в коде приложения, не в RLS.
*
* Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md
* План: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.1)
*/
return new class extends Migration
{
public function up(): void
{
$db = DB::connection('pgsql_supplier');
// ── 1. sales_tariffs ────────────────────────────────────────────────────
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_tariffs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
kind VARCHAR(20) NOT NULL
CHECK (kind IN ('topup_step', 'percent_oborot', 'fix_per_client')),
params JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
SQL);
// ── 2. sales_users ──────────────────────────────────────────────────────
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(10) NOT NULL DEFAULT 'manager'
CHECK (role IN ('manager', 'head')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
base_salary_rub DECIMAL(12,2) NOT NULL DEFAULT 0,
current_tariff_id BIGINT REFERENCES sales_tariffs(id),
created_by BIGINT REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
SQL);
// ── 3. sales_client_assignments ─────────────────────────────────────────
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_client_assignments (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id),
tariff_id BIGINT REFERENCES sales_tariffs(id),
tariff_kind VARCHAR(20),
tariff_params JSONB NOT NULL DEFAULT '{}'::jsonb,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_sca_sales_user
ON sales_client_assignments (sales_user_id)
SQL);
// ── 4. sales_attachment_requests ────────────────────────────────────────
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_attachment_requests (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
login_input VARCHAR(255) NOT NULL,
tenant_id BIGINT REFERENCES tenants(id),
status VARCHAR(12) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'not_found')),
comment TEXT,
decided_by BIGINT REFERENCES sales_users(id),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_sar_status
ON sales_attachment_requests (status)
SQL);
// ── 5. sales_payouts ────────────────────────────────────────────────────
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_payouts (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
amount_rub DECIMAL(12,2) NOT NULL CHECK (amount_rub > 0),
paid_on DATE NOT NULL,
comment TEXT,
created_by BIGINT NOT NULL REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_payout_user
ON sales_payouts (sales_user_id)
SQL);
// ── Append-only trigger для sales_payouts ───────────────────────────────
$db->statement(<<<'SQL'
CREATE OR REPLACE FUNCTION sales_payouts_no_mutate()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'sales_payouts is append-only';
END;
$$ LANGUAGE plpgsql
SQL);
$db->statement(<<<'SQL'
CREATE OR REPLACE TRIGGER trg_sales_payouts_no_mutate
BEFORE UPDATE OR DELETE ON sales_payouts
FOR EACH ROW EXECUTE FUNCTION sales_payouts_no_mutate()
SQL);
// ── GRANTs для crm_admin_user (idempotent DO-block) ─────────────────────
$db->statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE
ON sales_tariffs, sales_users, sales_client_assignments,
sales_attachment_requests
TO crm_admin_user;
GRANT SELECT, INSERT
ON sales_payouts
TO crm_admin_user;
GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA public
TO crm_admin_user;
END IF;
END
$$
SQL);
}
public function down(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement('DROP TABLE IF EXISTS sales_payouts CASCADE');
$db->statement('DROP TABLE IF EXISTS sales_attachment_requests CASCADE');
$db->statement('DROP TABLE IF EXISTS sales_client_assignments CASCADE');
$db->statement('DROP TABLE IF EXISTS sales_users CASCADE');
$db->statement('DROP TABLE IF EXISTS sales_tariffs CASCADE');
$db->statement('DROP FUNCTION IF EXISTS sales_payouts_no_mutate()');
}
};
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Таблица Sanctum personal_access_tokens для Bearer-токенов портала продаж.
*
* Проект использует SPA cookie-auth для основного кабинета (таблица раньше не
* создавалась), но портал отдела продаж (guard «sales») использует Sanctum
* API-токены: SalesAuthController->createToken(...). Для них нужна эта таблица.
*
* DDL идёт через соединение pgsql_supplier (как остальные системные таблицы)
* на проде дефолтная роль crm_app_user не имеет CREATE. Гранты выданы
* crm_admin_user: вся зона /api/sales проходит через admin-db (UseAdminConnection),
* включая логин и проверку токена, поэтому Sanctum читает/пишет токены под
* crm_admin_user. Миграция идемпотентна (Schema::hasTable + DO-блок грантов).
*
* Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md
* План: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.3)
*/
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection('pgsql_supplier');
if (! $schema->hasTable('personal_access_tokens')) {
$schema->create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
// Гранты для crm_admin_user (admin-db connection, которым работает портал
// продаж — включая логин/проверку токена). Идемпотентно, на dev no-op.
DB::connection('pgsql_supplier')->statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE
ON personal_access_tokens
TO crm_admin_user;
GRANT USAGE, SELECT
ON SEQUENCE personal_access_tokens_id_seq
TO crm_admin_user;
END IF;
END
$$
SQL);
}
public function down(): void
{
Schema::connection('pgsql_supplier')->dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Портал отдела продаж пересмотр состава тарифов (решение заказчика 03.07.2026).
*
* Оставляем ДВА вида тарифа вместо трёх:
* - daily_salary суточный оклад (ставка /день per-manager base_salary_rub);
* - topup_step процент от пополнений с порогом + разовым бонусом.
*
* Убираются: percent_oborot («Оклад + % от оборота») и fix_per_client («Фикс за клиента»).
*
* Меняем CHECK-ограничение sales_tariffs.kind. Перед сменой освобождаем FK-ссылки
* на тарифы уходящих видов (sales_users.current_tariff_id, sales_client_assignments.tariff_id)
* и удаляем сами тарифы, иначе новый CHECK не пройдёт. Снимки привязок
* (tariff_kind / tariff_params) не ограничены CHECK'ом их не трогаем; для уходящих
* видов SalesEarningsService возвращает 0 (default-ветка).
*
* Не на проде: фича на ветке feat/sales-portal-demo. schema.sql sales-таблиц не содержит
* (они добавлены миграцией 2026_07_01_100000). CHANGELOG_schema.md запись v8.61.
*
* Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md
* (блок «Тарифы ФИНАЛ заказчика (03.07.2026)»)
*/
return new class extends Migration
{
public function up(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement('ALTER TABLE sales_tariffs DROP CONSTRAINT IF EXISTS sales_tariffs_kind_check');
$db->statement(<<<'SQL'
UPDATE sales_users
SET current_tariff_id = NULL
WHERE current_tariff_id IN (
SELECT id FROM sales_tariffs WHERE kind IN ('percent_oborot', 'fix_per_client')
)
SQL);
$db->statement(<<<'SQL'
UPDATE sales_client_assignments
SET tariff_id = NULL
WHERE tariff_id IN (
SELECT id FROM sales_tariffs WHERE kind IN ('percent_oborot', 'fix_per_client')
)
SQL);
$db->statement("DELETE FROM sales_tariffs WHERE kind IN ('percent_oborot', 'fix_per_client')");
$db->statement(<<<'SQL'
ALTER TABLE sales_tariffs
ADD CONSTRAINT sales_tariffs_kind_check
CHECK (kind IN ('daily_salary', 'topup_step'))
SQL);
}
public function down(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement('ALTER TABLE sales_tariffs DROP CONSTRAINT IF EXISTS sales_tariffs_kind_check');
// Освобождаем ссылки на новый вид перед возвратом старого CHECK.
$db->statement(<<<'SQL'
UPDATE sales_users
SET current_tariff_id = NULL
WHERE current_tariff_id IN (
SELECT id FROM sales_tariffs WHERE kind = 'daily_salary'
)
SQL);
$db->statement(<<<'SQL'
UPDATE sales_client_assignments
SET tariff_id = NULL
WHERE tariff_id IN (
SELECT id FROM sales_tariffs WHERE kind = 'daily_salary'
)
SQL);
$db->statement("DELETE FROM sales_tariffs WHERE kind = 'daily_salary'");
$db->statement(<<<'SQL'
ALTER TABLE sales_tariffs
ADD CONSTRAINT sales_tariffs_kind_check
CHECK (kind IN ('topup_step', 'percent_oborot', 'fix_per_client'))
SQL);
}
};
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_order_checks история итоговой проверки заказа у поставщика.
*
* VerifySupplierOrderJob после ночного прогона робота перечитывает живой кабинет
* поставщика и сверяет каждую нашу строку с формулой; результат
* (ok / mismatch / unable_to_verify + список расхождений) пишется сюда одной строкой.
*
* Системная таблица (как supplier_sync_runs / supplier_deferred_sync) без RLS/
* tenant_id, пишет supplier-flow джоб под crm_supplier_worker (BYPASSRLS), читает SaaS-admin.
* Создаётся через pgsql_supplier + инлайн-GRANT (guard IF EXISTS) на проде миграции
* катит crm_migrator, поэтому дефолтные привилегии crm_supplier_worker не наследуются.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §6.
*/
return new class extends Migration
{
public function up(): void
{
$supplier = DB::connection('pgsql_supplier');
$supplier->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS supplier_order_checks (
id BIGSERIAL PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL,
sync_run_id BIGINT,
intended_rows INTEGER NOT NULL DEFAULT 0,
live_rows INTEGER NOT NULL DEFAULT 0,
mismatch_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL
CHECK (status IN ('ok','mismatch','unable_to_verify')),
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC)');
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_status ON supplier_order_checks (status)');
foreach (['crm_supplier_worker'] as $role) {
$supplier->statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT SELECT, INSERT ON supplier_order_checks TO {$role};
GRANT USAGE, SELECT ON SEQUENCE supplier_order_checks_id_seq TO {$role};
END IF;
END
\$\$
SQL);
}
}
public function down(): void
{
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS supplier_order_checks CASCADE');
}
};
+246
View File
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\SalesClientAssignment;
use App\Models\SalesPayout;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
/**
* Демо-данные портала отдела продаж ТОЛЬКО для dev/test.
*
* Наполняет портал так, как в прототипе liderra_v8_handoff/concepts/v8_sales.html:
* начальник + 3 менеджера, 4 тарифных экземпляра (3 вида), привязки клиентов
* (снимок тарифа) и журнал выплат.
*
* Запуск вручную (НЕ регистрируется в DatabaseSeeder):
* php artisan db:seed --class=SalesDemoSeeder
*
* Идемпотентность:
* - тарифы/менеджеры updateOrCreate/firstOrCreate по естественному ключу;
* - привязки firstOrCreate по tenant_id (UNIQUE);
* - выплаты (append-only, UPDATE/DELETE запрещены DB-триггером) создаются
* только если у менеджера ещё нет ни одной выплаты.
*
* Модели работают под DEFAULT connection; в проде портал ходит под admin-db
* (crm_admin_user), на dev/test все connections падают на общий DB_DATABASE,
* поэтому здесь спец-обработка connection не нужна. Тенанты только читаются.
*/
class SalesDemoSeeder extends Seeder
{
public function run(): void
{
if (app()->environment('production')) {
throw new \RuntimeException('SalesDemoSeeder запрещён на production.');
}
$tariffs = $this->seedTariffs();
$managers = $this->seedManagers($tariffs);
$this->seedAssignments($managers);
$this->seedPayouts($managers);
}
/**
* Тарифные экземпляры (2 вида: суточный оклад + процент от пополнений с порогом/бонусом).
* Ключ идемпотентности (kind, name).
*
* @return array<string, SalesTariff>
*/
private function seedTariffs(): array
{
// Суточный оклад — маркер режима; ставка ₽/день задаётся per-manager (base_salary_rub).
$daily = SalesTariff::updateOrCreate(
['kind' => 'daily_salary', 'name' => 'Суточный оклад'],
['is_active' => true, 'params' => []],
);
// Процент от пополнений: порог 30 000 → бонус 10 000, дальше ступени по сроку.
$stepStd = SalesTariff::updateOrCreate(
['kind' => 'topup_step', 'name' => 'Стандарт-пополнения'],
['is_active' => true, 'params' => [
'threshold' => 30000,
'reward' => 10000,
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 10],
['from' => 4, 'to' => 6, 'rate' => 7],
['from' => 7, 'to' => 9, 'rate' => 5],
['from' => 10, 'to' => 12, 'rate' => 3],
['from' => 13, 'to' => 24, 'rate' => 1],
],
]],
);
// Агрессивный: порог ниже (20 000), бонус выше (15 000), ставки выше.
$stepAgro = SalesTariff::updateOrCreate(
['kind' => 'topup_step', 'name' => 'Агрессивный'],
['is_active' => true, 'params' => [
'threshold' => 20000,
'reward' => 15000,
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 12],
['from' => 4, 'to' => 6, 'rate' => 9],
['from' => 7, 'to' => 9, 'rate' => 6],
['from' => 10, 'to' => 12, 'rate' => 4],
['from' => 13, 'to' => 24, 'rate' => 2],
],
]],
);
return [
'daily' => $daily,
'step_std' => $stepStd,
'step_agro' => $stepAgro,
];
}
/**
* Начальник + 3 менеджера. Ключ идемпотентности email.
*
* @param array<string, SalesTariff> $tariffs
* @return array<string, SalesUser>
*/
private function seedManagers(array $tariffs): array
{
$password = Hash::make('demo12345');
$head = SalesUser::updateOrCreate(
['email' => 'v.orlov@liderra.ru'],
[
'name' => 'Виктор Орлов',
'password' => $password,
'role' => 'head',
'is_active' => true,
'base_salary_rub' => 0,
'current_tariff_id' => null,
'created_by' => null,
],
);
// Новичок на суточном окладе: ставка 2 000 ₽/день (base_salary_rub).
$smirnov = SalesUser::updateOrCreate(
['email' => 'a.smirnov@liderra.ru'],
[
'name' => 'Алексей Смирнов',
'password' => $password,
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => 2000,
'current_tariff_id' => $tariffs['daily']->id,
'created_by' => $head->id,
],
);
// Опытный на проценте от пополнений (Стандарт).
$kotova = SalesUser::updateOrCreate(
['email' => 'm.kotova@liderra.ru'],
[
'name' => 'Мария Котова',
'password' => $password,
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => 0,
'current_tariff_id' => $tariffs['step_std']->id,
'created_by' => $head->id,
],
);
$lebedev = SalesUser::updateOrCreate(
['email' => 'i.lebedev@liderra.ru'],
[
'name' => 'Игорь Лебедев',
'password' => $password,
'role' => 'manager',
'is_active' => false, // «отпуск»
'base_salary_rub' => 0,
'current_tariff_id' => $tariffs['step_agro']->id,
'created_by' => $head->id,
],
);
return [
'head' => $head,
'smirnov' => $smirnov,
'kotova' => $kotova,
'lebedev' => $lebedev,
];
}
/**
* Привязки клиентов со СНИМКОМ текущего тарифа менеджера.
* Берём существующие тенанты; если их нет пропускаем (не падаем).
*
* @param array<string, SalesUser> $managers
*/
private function seedAssignments(array $managers): void
{
/** @var Collection<int, Tenant> $tenants */
$tenants = Tenant::query()->limit(8)->get();
if ($tenants->isEmpty()) {
$this->command->warn('SalesDemoSeeder: тенантов нет — привязки клиентов пропущены.');
return;
}
// Распределяем тенанты по 3 менеджерам по кругу.
$roundRobin = [$managers['smirnov'], $managers['kotova'], $managers['lebedev']];
foreach ($tenants as $index => $tenant) {
$manager = $roundRobin[$index % count($roundRobin)];
$tariff = $manager->currentTariff; // снимок берём из тарифа менеджера
SalesClientAssignment::firstOrCreate(
['tenant_id' => $tenant->id], // UNIQUE — повторный запуск не дублирует
[
'sales_user_id' => $manager->id,
'tariff_id' => $tariff->id,
'tariff_kind' => $tariff->kind,
'tariff_params' => $tariff->params,
'assigned_at' => now()->subMonthsNoOverflow(rand(1, 6)),
],
);
}
}
/**
* Выплаты (append-only) по 1-2 на менеджера, суммы из прототипа.
* Создаём только если у менеджера ещё нет выплат (нельзя дублировать).
*
* @param array<string, SalesUser> $managers
*/
private function seedPayouts(array $managers): void
{
$head = $managers['head'];
$plan = [
'smirnov' => [35000, 25000],
'kotova' => [30000],
'lebedev' => [40000],
];
foreach ($plan as $key => $amounts) {
$manager = $managers[$key];
if ($manager->payouts()->count() > 0) {
continue; // уже есть выплаты — не дублируем append-only записи
}
foreach ($amounts as $offset => $amount) {
SalesPayout::create([
'sales_user_id' => $manager->id,
'amount_rub' => $amount,
'paid_on' => now()->subMonthsNoOverflow($offset)->startOfMonth()->addDays(24),
'comment' => $offset === 0 ? 'Зарплата (демо)' : 'Аванс (демо)',
'created_by' => $head->id,
]);
}
}
}
}
+46 -1
View File
@@ -5,7 +5,8 @@
"packages": {
"": {
"dependencies": {
"lucide-vue-next": "^1.0.0"
"lucide-vue-next": "^1.0.0",
"playwright": "1.59.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -7787,6 +7788,50 @@
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/playwright": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz",
"integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz",
"integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+2 -1
View File
@@ -50,6 +50,7 @@
"vuetify": "^3.12.5"
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
"lucide-vue-next": "^1.0.0",
"playwright": "1.59.0"
}
}
+235 -3
View File
@@ -79,7 +79,7 @@ parameters:
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Strict comparison using \!\=\= between int and null will always evaluate to true\.$#'
message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: app/Http/Middleware/SetTenantContext.php
@@ -240,6 +240,60 @@ parameters:
count: 7
path: tests/Feature/Account/ChangePasswordTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Sales/SalesGuardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesGuardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<list\|null\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -657,7 +711,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 13
count: 15
path: tests/Feature/AdminTenantsIndexTest.php
-
@@ -1317,7 +1371,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 5
count: 9
path: tests/Feature/Console/IncidentsWatchFailuresTest.php
-
@@ -2958,6 +3012,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
@@ -3000,6 +3060,12 @@ parameters:
count: 6
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/VerifySupplierOrderJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -3227,3 +3293,169 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/ExpireInvoicesTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientCardTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Sales/SalesClientCardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientCardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesOverviewTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Sales/SalesOverviewTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesOverviewTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 10
path: tests/Feature/Sales/SalesAttachmentApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesAttachmentApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesAttachmentApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 20
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<list\|null\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 10
path: tests/Feature/Sales/SalesPayoutTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesPayoutTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesPayoutTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/Sales/SalesManagerAccountTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesManagerAccountTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/Sales/SalesInvoiceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesInvoiceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesInvoiceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Sales/SalesIncomeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesIncomeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesDashboardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesDashboardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesPerformanceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesPerformanceTest.php
+40
View File
@@ -124,6 +124,10 @@ interface AdminTenantsStats {
export interface ListAdminTenantsParams {
status?: string;
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
statuses?: string;
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
tariffs?: string;
search?: string;
limit?: number;
offset?: number;
@@ -572,3 +576,39 @@ export async function executePdErasure(id: number, adminUserId?: number): Promis
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
return data;
}
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
export interface AdminInvoiceRow {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface ListAdminInvoicesParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
}
export interface ListAdminInvoicesResponse {
data: AdminInvoiceRow[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
return data;
}
export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
+22 -1
View File
@@ -73,7 +73,19 @@ export interface BillingInvoice {
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
has_pdf: boolean;
has_act: boolean;
pdf_url: string | null;
act_url: string | null;
}
/** Ответ POST /api/billing/invoices — созданный счёт. */
export interface CreatedInvoice {
id: number;
invoice_number: string;
amount_total: string;
pdf_url: string;
}
/** GET /api/billing/transactions — пагинированная история транзакций. */
@@ -82,12 +94,21 @@ export async function getTransactions(params: { page?: number; type?: string }):
return data;
}
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
/** GET /api/billing/invoices — счета тенанта. */
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
return data;
}
/** POST /api/billing/invoices — выставить счёт по реквизитам тенанта (оплата по счёту). */
export async function createInvoice(amountRub: number): Promise<CreatedInvoice> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ invoice: CreatedInvoice }>('/api/billing/invoices', {
amount_rub: amountRub,
});
return data.invoice;
}
/**
* Результат POST /api/billing/topup — две формы:
* • заглушка (флаг ВЫКЛ): transaction + balance_rub (мгновенное зачисление);
+913
View File
@@ -0,0 +1,913 @@
import axios from 'axios';
/**
* API-клиент для портала отдела продаж (/api/sales/*).
*
* Использует Bearer-токен из salesAuth store (localStorage 'sales_token').
* НЕ использует Sanctum cookie/CSRF — это отдельный auth через токен.
*
* Base path: /api/sales
*/
export interface SalesUser {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
}
export interface SalesLoginResponse {
token: string;
user: SalesUser;
}
// ─── helpers ────────────────────────────────────────────────────────────────
function getToken(): string | null {
try {
return localStorage.getItem('sales_token');
} catch {
return null;
}
}
function authHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/**
* Извлекает читаемое сообщение об ошибке из ответа API.
*/
export function extractSalesErrorMessage(error: unknown, fallback = 'Произошла ошибка. Попробуйте позже.'): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { message?: string } | undefined;
if (data?.message) return data.message;
if (error.response?.status === 401) return 'Неверный email или пароль.';
if (error.response?.status === 403) return 'Нет прав на это действие.';
if (error.response?.status === 422) {
const errData = error.response.data as { errors?: Record<string, string[]> } | undefined;
const firstField = errData?.errors ? Object.values(errData.errors)[0] : undefined;
if (firstField?.[0]) return firstField[0];
}
if (error.response?.status === 500) return 'Внутренняя ошибка сервера.';
}
return fallback;
}
// ─── types ───────────────────────────────────────────────────────────────────
export interface SalesClientRow {
tenant_id: number;
organization_name: string;
inn: string | null;
subject_type: string | null;
last_activity_at: string | null; // ISO datetime or null
balance_rub: string;
status: 'trial' | 'suspended' | 'overdue' | 'active' | string;
tariff_name: string | null;
projects_count: number;
runway_days: number | null;
leads_delivered: number;
oborot_rub: number;
earned_rub: number | null;
}
export interface SalesClientsParams {
period: string;
from?: string;
to?: string;
search?: string;
}
// ─── auth endpoints ──────────────────────────────────────────────────────────
/**
* POST /api/sales/auth/login → { token, user }
*/
export async function salesLogin(email: string, password: string): Promise<SalesLoginResponse> {
const { data } = await axios.post<SalesLoginResponse>(
'/api/sales/auth/login',
{ email, password },
{ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } },
);
return data;
}
/**
* GET /api/sales/auth/me (Bearer) → { id, name, email, role }
*/
export async function salesMe(): Promise<SalesUser> {
const { data } = await axios.get<SalesUser>('/api/sales/auth/me', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/auth/logout (Bearer)
*/
export async function salesLogout(): Promise<void> {
await axios.post(
'/api/sales/auth/logout',
{},
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
}
// ─── clients endpoint ─────────────────────────────────────────────────────────
/**
* GET /api/sales/clients?period=...&from=...&to=...&search=... (Bearer)
* → { data: SalesClientRow[] }
*/
export async function listSalesClients(params: SalesClientsParams): Promise<SalesClientRow[]> {
const { data } = await axios.get<{ data: SalesClientRow[] }>('/api/sales/clients', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
// ─── client card types ────────────────────────────────────────────────────────
export interface SalesClientCardProfile {
organization_name: string;
contact_email: string | null;
contact_name: string | null;
contact_phone: string | null;
inn: string | null;
subject_type: string | null;
created_at: string; // ISO
desired_daily_numbers: number | null;
last_activity_at: string | null; // ISO
}
export interface SalesClientCardKpi {
balance_rub: string;
runway_days: number | null;
projects_count: number;
leads_delivered: number;
leads_target: number;
avg_lead_price_rub: number;
earned_rub: number | null;
}
export interface SalesClientCardProject {
id: number;
name: string;
signal_type: 'site' | 'call' | 'sms';
region: string[];
daily_limit_target: number;
delivered_today: number;
status: 'active' | 'paused';
}
export interface SalesClientCardLeadByDay {
date: string; // YYYY-MM-DD
count: number;
oborot_rub: number;
}
export interface SalesClientCardRecentLead {
received_at: string; // ISO
phone_masked: string;
region: string;
source: string;
project: string;
}
export interface SalesClientCardActivityItem {
created_at: string; // ISO
type: string;
amount_rub: string;
description: string;
}
export interface SalesClientCard {
profile: SalesClientCardProfile;
kpi: SalesClientCardKpi;
projects: SalesClientCardProject[];
leads_by_day: SalesClientCardLeadByDay[];
recent_leads: SalesClientCardRecentLead[];
activity: SalesClientCardActivityItem[];
}
// ─── overview types ───────────────────────────────────────────────────────────
export interface SalesOverviewTotals {
clients_count: number;
active: number;
trial: number;
overdue: number;
balance_sum_rub: string;
leads_delivered: number;
oborot_rub: number;
earned_rub: number | null;
}
export interface SalesOverviewAttentionRow {
tenant_id: number;
organization_name: string;
status: string;
balance_rub: string;
runway_days: number | null;
}
export interface SalesOverviewTopClient {
tenant_id: number;
organization_name: string;
leads_delivered: number;
oborot_rub: number;
}
export interface SalesOverview {
totals: SalesOverviewTotals;
attention: SalesOverviewAttentionRow[];
top_clients: SalesOverviewTopClient[];
}
export interface SalesOverviewParams {
period: string;
from?: string;
to?: string;
}
// ─── overview endpoint ────────────────────────────────────────────────────────
/**
* GET /api/sales/overview?period=...&from=...&to=... (Bearer)
* → SalesOverview
*/
export async function getSalesOverview(params: SalesOverviewParams): Promise<SalesOverview> {
const { data } = await axios.get<SalesOverview>('/api/sales/overview', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── client card endpoint ─────────────────────────────────────────────────────
/**
* GET /api/sales/clients/{tenantId}?period=...&from=...&to=... (Bearer)
* → SalesClientCard
* 403 если клиент не закреплён за менеджером.
*/
export async function getSalesClientCard(
tenantId: string | number,
params: SalesClientsParams,
): Promise<SalesClientCard> {
const { data } = await axios.get<SalesClientCard>(`/api/sales/clients/${tenantId}`, {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── attachments (привязка клиентов) types ────────────────────────────────────
export type SalesAttachmentStatus = 'pending' | 'approved' | 'rejected' | 'not_found';
/**
* Строка заявки на привязку клиента к менеджеру.
* Форма зависит от роли (см. бэк-контракт GET /api/sales/attachments):
* - менеджер: базовые поля;
* - начальник (pending): + manager_name, hint;
* - начальник (history): + manager_name.
*/
export interface SalesAttachmentRow {
id: number;
login_input: string;
status: SalesAttachmentStatus;
status_label: string;
tenant_id: number | null;
organization_name: string | null;
comment: string | null;
created_at: string | null; // ISO
decided_at: string | null; // ISO
/** Только у начальника: имя менеджера-автора заявки */
manager_name?: string;
/**
* Только у начальника в очереди pending: результат проверки.
* 'свободен' | 'уже за: Имя' | null (когда клиент не найден).
*/
hint?: string | null;
}
/**
* Результат отправки заявки / решения по ней.
* POST /api/sales/attachments → 201; POST /api/sales/attachments/{id}/decide → 200.
*/
export interface SalesAttachmentSubmitResult {
id: number;
login_input: string;
status: SalesAttachmentStatus;
status_label: string;
tenant_id: number | null;
comment: string | null;
}
/**
* Ответ GET /api/sales/attachments для начальника.
*/
export interface SalesAttachmentQueue {
pending: SalesAttachmentRow[];
history: SalesAttachmentRow[];
}
// ─── attachments endpoints ────────────────────────────────────────────────────
/**
* POST /api/sales/attachments (Bearer, менеджер)
* Тело: { login }. Клиент не найден → status='not_found' (201, не ошибка).
* Уже за этим менеджером → 422.
*/
export async function submitAttachment(login: string): Promise<SalesAttachmentSubmitResult> {
const { data } = await axios.post<SalesAttachmentSubmitResult>(
'/api/sales/attachments',
{ login },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
/**
* GET /api/sales/attachments (Bearer, менеджер) → { data: SalesAttachmentRow[] }
* Только свои заявки, newest first.
*/
export async function listMyAttachments(): Promise<SalesAttachmentRow[]> {
const { data } = await axios.get<{ data: SalesAttachmentRow[] }>('/api/sales/attachments', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* GET /api/sales/attachments (Bearer, начальник) → { pending, history }
*/
export async function listAttachmentQueue(): Promise<SalesAttachmentQueue> {
const { data } = await axios.get<SalesAttachmentQueue>('/api/sales/attachments', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/attachments/{id}/decide (Bearer, только начальник)
* action='approve' сам переназначает (если клиент был за другим менеджером).
* 403 если не начальник.
*/
export async function decideAttachment(
id: number,
action: 'approve' | 'reject',
comment?: string,
): Promise<SalesAttachmentSubmitResult> {
const { data } = await axios.post<SalesAttachmentSubmitResult>(
`/api/sales/attachments/${id}/decide`,
{ action, ...(comment ? { comment } : {}) },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
// ─── tariffs (тарифы менеджеров) types ─────────────────────────────────────────
/**
* Тип (вид) тарифа менеджера (модель 03.07.2026):
* - daily_salary — суточный оклад (ставка ₽/день = base_salary_rub, per-manager);
* - topup_step — процент от пополнений с порогом + разовым бонусом и ступенями по сроку.
*/
export type SalesTariffKind = 'daily_salary' | 'topup_step';
/** Одна ступень для topup_step: срок клиента у менеджера from–to мес и ставка %. */
export interface SalesTariffStep {
from: number;
to: number;
rate: number;
}
/**
* Тариф менеджера. Форма params зависит от kind:
* - daily_salary: {} (ставка ₽/день задаётся per-manager при назначении);
* - topup_step: { threshold: number, reward: number, periods: SalesTariffStep[] }.
*/
export interface SalesTariff {
id: number;
name: string;
kind: SalesTariffKind;
params: Record<string, unknown>;
}
/** Строка менеджера в таблице назначения тарифа (для новых клиентов). */
export interface SalesTariffManager {
id: number;
name: string;
current_tariff_id: number | null;
current_tariff_name: string | null;
current_tariff_kind: SalesTariffKind | null;
base_salary_rub: number;
clients_count: number;
topups_rub: number;
oborot_rub: number;
estimated_earned_rub: number;
}
export interface SalesTariffsResponse {
tariffs: SalesTariff[];
managers: SalesTariffManager[];
}
export interface SalesTariffsParams {
period: string;
from?: string;
to?: string;
}
/** Результат POST /tariffs/assign. */
export interface SalesTariffAssignResult {
id: number;
name: string;
current_tariff_id: number | null;
base_salary_rub: number;
}
// ─── tariffs endpoints ─────────────────────────────────────────────────────────
/**
* GET /api/sales/tariffs?period=...&from=...&to=... (Bearer, начальник)
* → { tariffs, managers }
*/
export async function listSalesTariffs(params: SalesTariffsParams): Promise<SalesTariffsResponse> {
const { data } = await axios.get<SalesTariffsResponse>('/api/sales/tariffs', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/tariffs (Bearer, начальник) → 201 SalesTariff
* Валидация params по kind; ошибка → 422.
*/
export async function createSalesTariff(payload: {
name: string;
kind: SalesTariffKind;
params: Record<string, unknown>;
}): Promise<SalesTariff> {
const { data } = await axios.post<SalesTariff>('/api/sales/tariffs', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* PUT /api/sales/tariffs/{id} (Bearer, начальник) → 200 SalesTariff
* kind менять нельзя; передаём только name + params.
*/
export async function updateSalesTariff(
id: number,
payload: { name: string; params: Record<string, unknown> },
): Promise<SalesTariff> {
const { data } = await axios.put<SalesTariff>(`/api/sales/tariffs/${id}`, payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/tariffs/assign (Bearer, начальник) → 200
* Назначает менеджеру текущий тариф (для НОВЫХ клиентов).
*/
export async function assignSalesTariff(payload: {
manager_id: number;
tariff_id: number;
base_salary_rub?: number;
}): Promise<SalesTariffAssignResult> {
const { data } = await axios.post<SalesTariffAssignResult>('/api/sales/tariffs/assign', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── payouts / income (выплаты менеджерам · мой доход) types ───────────────────
/**
* Одна запись журнала выплаты менеджеру.
* Менеджер видит только свои выплаты, начальник — все.
*/
export interface SalesPayoutRow {
id: number;
sales_user_id: number;
manager_name: string | null;
amount_rub: number;
paid_on: string; // YYYY-MM-DD
comment: string | null;
created_at: string; // ISO
creator_name: string | null;
}
/**
* Строка остатка к выплате по менеджеру (только для начальника).
* remaining_rub = accrued_rub paid_all_time_rub (нарастающим итогом).
*/
export interface SalesRemainingRow {
manager_id: number;
name: string;
clients_count: number;
oborot_rub: number;
accrued_rub: number;
paid_period_rub: number;
paid_all_time_rub: number;
remaining_rub: number;
}
/** Итоги дохода менеджера за период. */
export interface SalesIncomeTotals {
oborot_rub: number;
accrued_rub: number;
paid_all_time_rub: number;
to_pay_period_rub: number;
}
/** Начисление менеджеру по одному клиенту за период. */
export interface SalesIncomePerClient {
tenant_id: number;
organization_name: string | null;
tariff_kind: string | null;
tariff_label: string;
topups_rub: number;
earned_rub: number;
}
export interface SalesIncomeResponse {
totals: SalesIncomeTotals;
per_client: SalesIncomePerClient[];
payouts: SalesPayoutRow[];
}
export interface SalesPayoutsParams {
period: string;
from?: string;
to?: string;
}
// ─── payouts / income endpoints ────────────────────────────────────────────────
/**
* GET /api/sales/payouts (Bearer)
* Менеджер видит свои выплаты, начальник — все. → { data: SalesPayoutRow[] }
*/
export async function listSalesPayouts(): Promise<SalesPayoutRow[]> {
const { data } = await axios.get<{ data: SalesPayoutRow[] }>('/api/sales/payouts', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* GET /api/sales/payouts/remaining?period=... (Bearer, начальник)
* → { data: SalesRemainingRow[] }
*/
export async function getSalesPayoutsRemaining(params: SalesPayoutsParams): Promise<SalesRemainingRow[]> {
const { data } = await axios.get<{ data: SalesRemainingRow[] }>('/api/sales/payouts/remaining', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* POST /api/sales/payouts (Bearer, начальник) → 201 SalesPayoutRow
* Ошибка валидации → 422.
*/
export async function createSalesPayout(payload: {
manager_id: number;
amount_rub: number;
paid_on: string;
comment?: string;
}): Promise<SalesPayoutRow> {
const { data } = await axios.post<SalesPayoutRow>('/api/sales/payouts', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* GET /api/sales/income?period=... (Bearer, менеджер) → SalesIncomeResponse
*/
export async function getSalesIncome(params: SalesPayoutsParams): Promise<SalesIncomeResponse> {
const { data } = await axios.get<SalesIncomeResponse>('/api/sales/income', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── invoices (счета · отметка оплаты) types ───────────────────────────────────
/** Строка счёта в таблице «Счета» портала продаж (только для начальника). */
export interface SalesInvoiceRow {
id: number;
invoice_number: string;
amount_total: string | number;
status: 'issued' | 'paid' | 'overdue' | 'cancelled';
issued_at: string; // ISO
expires_at: string | null; // ISO or null
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface SalesInvoicesResponse {
data: SalesInvoiceRow[];
meta: {
current_page: number;
last_page: number;
total: number;
per_page: number;
};
}
export interface SalesInvoicesParams {
status?: string;
search?: string;
per_page?: number;
}
// ─── invoices endpoints ────────────────────────────────────────────────────────
/**
* GET /api/sales/invoices?status=...&search=...&per_page=... (Bearer, начальник)
* → { data: SalesInvoiceRow[], meta: {...} }
*/
export async function listSalesInvoices(params: SalesInvoicesParams): Promise<SalesInvoicesResponse> {
const { data } = await axios.get<SalesInvoicesResponse>('/api/sales/invoices', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/invoices/{id}/mark-paid (Bearer, начальник) → { status: 'ok' }
* Зачисляет баланс клиенту + формирует Акт на бэкенде.
*/
export async function markSalesInvoicePaid(id: number): Promise<{ status: string }> {
const { data } = await axios.post<{ status: string }>(
`/api/sales/invoices/${id}/mark-paid`,
{},
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
// ─── dashboard overview (сводка отдела · начальник) types ──────────────────────
/** KPI-плитки сводки отдела начальника. */
export interface SalesDashboardKpi {
managers_count: number;
managers_active: number;
managers_vacation: number;
clients_count: number;
balance_sum_rub: string;
oborot_rub: number;
paid_period_rub: number;
}
/** Счётчики-алерты сводки отдела (кликабельные плитки). */
export interface SalesDashboardAlerts {
invoices_awaiting: number;
attachments_pending: number;
clients_balance_problem: number;
}
/** Строка таблицы «Результативность менеджеров». */
export interface SalesDashboardPerformanceRow {
manager_id: number;
name: string;
clients_count: number;
leads_delivered: number;
oborot_rub: number;
paid_all_time_rub: number;
earned_rub: number;
}
export interface SalesDashboardOverview {
kpi: SalesDashboardKpi;
alerts: SalesDashboardAlerts;
performance: SalesDashboardPerformanceRow[];
}
export interface SalesDashboardParams {
period: string;
from?: string;
to?: string;
}
// ─── dashboard overview endpoint ───────────────────────────────────────────────
/**
* GET /api/sales/dashboard/overview?period=...&from=...&to=... (Bearer, начальник)
* → SalesDashboardOverview
*/
export async function getSalesDashboardOverview(params: SalesDashboardParams): Promise<SalesDashboardOverview> {
const { data } = await axios.get<SalesDashboardOverview>('/api/sales/dashboard/overview', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── managers performance (результативность менеджеров · начальник) types ──────
/**
* Строка таблицы «Результативность менеджеров» (Task 6.2b).
* status: 'active' — работает, 'vacation' — в отпуске.
*/
export interface SalesPerformanceRow {
manager_id: number;
name: string;
email: string;
clients_count: number;
active_clients: number;
leads_delivered: number;
oborot_rub: number;
paid_all_time_rub: number;
earned_rub: number;
status: 'active' | 'vacation';
}
export interface SalesPerformanceParams {
period: string;
from?: string;
to?: string;
search?: string;
}
// ─── managers performance endpoint ─────────────────────────────────────────────
/**
* GET /api/sales/managers/performance?period=...&from=...&to=...&search=... (Bearer, начальник)
* → { data: SalesPerformanceRow[] }
*/
export async function getSalesManagersPerformance(params: SalesPerformanceParams): Promise<SalesPerformanceRow[]> {
const { data } = await axios.get<{ data: SalesPerformanceRow[] }>('/api/sales/managers/performance', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
// ─── managers (создание менеджера + список · начальник) types ──────────────────
/**
* Строка менеджера в таблице «Менеджеры отдела» (Task 7.1b).
* is_active=false трактуется как «Отпуск».
*/
export interface SalesManagerRow {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
is_active: boolean;
clients_count: number;
paid_all_time_rub: number;
}
/** Результат POST /api/sales/managers → 201. */
export interface SalesManagerCreateResult {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
is_active: boolean;
}
// ─── managers endpoints ────────────────────────────────────────────────────────
/**
* GET /api/sales/managers (Bearer, начальник) → { data: SalesManagerRow[] }
*/
export async function listSalesManagers(): Promise<SalesManagerRow[]> {
const { data } = await axios.get<{ data: SalesManagerRow[] }>('/api/sales/managers', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* POST /api/sales/managers (Bearer, начальник) → 201 SalesManagerCreateResult
* Дубль email / невалидные данные → 422.
*/
export async function createSalesManager(payload: {
name: string;
email: string;
password: string;
role: 'manager' | 'head';
}): Promise<SalesManagerCreateResult> {
const { data } = await axios.post<SalesManagerCreateResult>('/api/sales/managers', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
+3
View File
@@ -16,6 +16,7 @@ import AdminLayout from '../layouts/AdminLayout.vue';
import AppLayout from '../layouts/AppLayout.vue';
import AuthLayout from '../layouts/AuthLayout.vue';
import PublicLayout from '../layouts/PublicLayout.vue';
import SalesLayout from '../layouts/SalesLayout.vue';
const route = useRoute();
const layoutName = computed(() => route.meta.layout ?? 'app');
@@ -29,8 +30,10 @@ const DevIndexOverlay: Component | null = import.meta.env.DEV
<template>
<AuthLayout v-if="layoutName === 'auth'" />
<RouterView v-else-if="layoutName === 'error'" />
<RouterView v-else-if="layoutName === 'sales-login'" />
<PublicLayout v-else-if="layoutName === 'public'" />
<AdminLayout v-else-if="layoutName === 'admin'" />
<SalesLayout v-else-if="layoutName === 'sales'" />
<AppLayout v-else />
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
</template>
@@ -61,7 +61,7 @@ defineExpose({ load, invoices });
</v-alert>
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
Счета появятся после первой оплаты.
Здесь появятся выставленные вами счета на оплату.
</div>
<ul v-else class="invoices-list pa-2 ma-0">
@@ -72,9 +72,30 @@ defineExpose({ load, invoices });
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<v-btn variant="text" size="small" prepend-icon="mdi-file-pdf-box" :disabled="!inv.has_pdf">
PDF
</v-btn>
<span class="inv-actions">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:href="inv.pdf_url ?? undefined"
target="_blank"
:disabled="!inv.has_pdf"
:data-testid="`inv-pdf-${inv.id}`"
>
Счёт
</v-btn>
<v-btn
v-if="inv.has_act"
variant="text"
size="small"
prepend-icon="mdi-file-document-check-outline"
:href="inv.act_url ?? undefined"
target="_blank"
:data-testid="`inv-act-${inv.id}`"
>
Акт
</v-btn>
</span>
</li>
</ul>
</v-card>
@@ -141,4 +162,9 @@ defineExpose({ load, invoices });
font-weight: 500;
color: #081319;
}
.inv-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
</style>
@@ -6,6 +6,8 @@
*
* Billing v2 Spec A §3.4.8.
*/
import { tierRangeText } from '../../utils/pricingTiers';
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
@@ -18,14 +20,7 @@ const props = defineProps<{
}>();
function rangeText(idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
return tierRangeText(props.tiers, idx);
}
</script>
@@ -1,21 +1,24 @@
<script setup lang="ts">
/**
* TopupDialog — диалог пополнения рублёвого баланса (audit E1).
* TopupDialog — диалог пополнения рублёвого баланса.
*
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
* платёжного шлюза — реальная оплата post-Б-1). При успехе эмитит
* `success` с новым балансом и закрывается.
* Два способа:
* • «Карта» — POST /api/billing/topup (заглушка мгновенного зачисления ИЛИ
* редирект на ЮKassa, если флаг billing_yookassa_enabled ВКЛ).
* • «По счёту» (для юрлиц) — POST /api/billing/invoices: формирует PDF-счёт по
* реквизитам тенанта, баланс пополнится после ручной отметки оплаты админом.
*/
import { ref, computed, watch } from 'vue';
import { topup } from '../../api/billing';
import { topup, createInvoice } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { redirectTo } from '../../utils/redirect';
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string] }>();
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
const PRESETS = [1000, 5000, 10000, 25000];
const method = ref<'card' | 'invoice'>('card');
const amount = ref<number | null>(null);
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
@@ -29,12 +32,14 @@ const amountError = computed<string | null>(() => {
const canSubmit = computed(() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value);
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
// NewDealDialog) — нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
const submitLabel = computed(() => (method.value === 'invoice' ? 'Сформировать счёт' : 'Пополнить'));
// Сброс состояния при каждом открытии диалога — нет префилла прошлой суммы/ошибки.
watch(model, (open) => {
if (open) {
amount.value = null;
errorMsg.value = null;
method.value = 'card';
}
});
@@ -42,11 +47,26 @@ function setPreset(value: number): void {
amount.value = value;
}
function openPdf(url: string): void {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(url, '_blank');
}
}
async function submit(): Promise<void> {
if (!canSubmit.value || amount.value === null) return;
submitting.value = true;
errorMsg.value = null;
try {
if (method.value === 'invoice') {
const invoice = await createInvoice(amount.value);
openPdf(invoice.pdf_url);
emit('invoiced', invoice.invoice_number);
model.value = false;
amount.value = null;
return;
}
const res = await topup(amount.value);
// Реальный шлюз (флаг ВКЛ): редирект на страницу оплаты ЮKassa.
if (res.confirmation_url) {
@@ -71,7 +91,7 @@ function close(): void {
errorMsg.value = null;
}
defineExpose({ amount, submit, canSubmit, errorMsg });
defineExpose({ method, amount, submit, canSubmit, errorMsg });
</script>
<template>
@@ -79,6 +99,18 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
<v-btn-toggle
v-model="method"
mandatory
density="comfortable"
color="primary"
class="mb-4"
data-testid="topup-method"
>
<v-btn value="card">Картой</v-btn>
<v-btn value="invoice">По счёту (для юрлиц)</v-btn>
</v-btn-toggle>
<v-text-field
v-model.number="amount"
type="number"
@@ -95,8 +127,16 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
</v-chip>
</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс пополняется сразу.
<v-alert
v-if="method === 'invoice'"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
Счёт сформируется по реквизитам вашей компании. Оплатите его банковским переводом
баланс пополнится после поступления денег. Закрывающий документ (Акт) сформируется
автоматически после оплаты.
</v-alert>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
@@ -107,7 +147,7 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
Пополнить
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-card>
@@ -35,11 +35,11 @@ function formatRelative(minutes: number): string {
<header class="hero pa-5">
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
<div class="hero-row mt-1">
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
<h2 class="hero-name text-h5 ym-hide-content">{{ deal.name }}</h2>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
</div>
<div class="hero-meta mt-2">
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link ym-hide-content">{{ deal.phone }}</a>
<span class="sep">·</span>
<span class="text-caption text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
@@ -37,7 +37,7 @@ const hasActiveFilter = () => props.filterStatus !== null || props.filterProject
density="compact"
hide-details
clearable
class="filters-search"
class="filters-search ym-disable-keys"
data-testid="filter-search-phone"
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
/>
@@ -70,7 +70,7 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ item.phone }}</span>
<span class="num ld-mono ym-hide-content">{{ item.phone }}</span>
</template>
<template #[`item.project`]="{ item }: { item: MockDeal }">
@@ -94,9 +94,11 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
</template>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
<span
class="cell-comment ym-hide-content"
:class="{ 'text-medium-emphasis': !item.comment }"
>{{ item.comment || '—' }}</span
>
</template>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
@@ -115,7 +117,7 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
:aria-label="`Выбрать сделку #${(item as MockDeal).id}`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
@@ -216,6 +216,7 @@ function close() {
variant="outlined"
density="comfortable"
autofocus
class="ym-disable-keys"
:error-messages="errors.name ? [errors.name] : []"
data-testid="field-name"
/>
@@ -228,6 +229,7 @@ function close() {
variant="outlined"
density="comfortable"
inputmode="tel"
class="ym-disable-keys"
:error-messages="errors.phone ? [errors.phone] : []"
data-testid="field-phone"
/>
@@ -25,8 +25,8 @@ function formatCost(cost: number): string {
density="compact"
@click="emit('open', deal.id)"
>
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-name ym-hide-content">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis ym-hide-content">{{ deal.phone }}</div>
<div class="card-meta mt-2">
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
<span class="card-cost num">{{ deal.costKopecks != null ? formatCost(deal.costKopecks / 100) : '—' }}</span>
@@ -0,0 +1,67 @@
<script setup lang="ts">
/**
* HelpHint — знак вопроса «?» с тултипом.
*
* Источник дизайна: v8_sales.html .help "?" affordance.
* Рендерит маленькую иконку «?» в кружке; при наведении показывает текст.
* Используется внутри заголовков таблиц и KPI-плиток.
*
* Пример: <HelpHint text="На сколько дней хватит баланса при текущем расходе" />
*/
defineProps<{
/** Текст подсказки, который показывается в тултипе */
text: string;
}>();
</script>
<template>
<v-tooltip :text="text" location="top" max-width="260">
<template #activator="{ props: tooltipProps }">
<span
v-bind="tooltipProps"
class="help-hint"
role="button"
tabindex="0"
:aria-label="`Подсказка: ${text}`"
data-testid="help-hint"
>?</span
>
</template>
</v-tooltip>
</template>
<style scoped>
.help-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: #f0ede4;
border: 1px solid #d9d5cd;
color: #66635c;
font-size: 9px;
font-weight: 700;
line-height: 1;
margin-left: 4px;
cursor: help;
vertical-align: middle;
font-family: var(--font-ui, 'Inter', system-ui, sans-serif);
flex-shrink: 0;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.help-hint:hover,
.help-hint:focus-visible {
background: #e1eeea;
color: #084635;
border-color: #b6d9cf;
outline: 2px solid #0f6e56;
outline-offset: 2px;
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* PeriodPicker — выбор периода для портала отдела продаж.
*
* Источник дизайна: v8_sales.html .fbtn select#period-sel + #custom-period.
* При выборе «Произвольный» — показывает два date-поля (from / to).
* Записывает состояние в salesPeriod store.
*/
import { computed } from 'vue';
import { useSalesPeriodStore } from '../../stores/salesPeriod';
import type { PeriodKind } from '../../stores/salesPeriod';
const period = useSalesPeriodStore();
interface PeriodOption {
value: PeriodKind;
label: string;
}
const options: PeriodOption[] = [
{ value: 'this', label: 'Этот месяц' },
{ value: 'prev', label: 'Прошлый месяц' },
{ value: 'prev2', label: 'Позапрошлый месяц' },
{ value: 'custom', label: 'Произвольный период…' },
];
const selectedKind = computed({
get: () => period.kind,
set: (v: PeriodKind) => {
if (v !== 'custom') {
period.setPeriod({ kind: v });
} else {
period.setPeriod({ kind: 'custom', from: period.from, to: period.to });
}
},
});
const customFrom = computed({
get: () => period.from ?? '',
set: (v: string) => period.setPeriod({ kind: 'custom', from: v, to: period.to }),
});
const customTo = computed({
get: () => period.to ?? '',
set: (v: string) => period.setPeriod({ kind: 'custom', from: period.from, to: v }),
});
const isCustom = computed(() => period.kind === 'custom');
</script>
<template>
<div class="period-picker" role="group" aria-label="Период данных">
<v-select
v-model="selectedKind"
:items="options"
item-title="label"
item-value="value"
variant="outlined"
density="compact"
hide-details
class="period-select"
aria-label="Выбор периода"
data-testid="period-kind-select"
/>
<template v-if="isCustom">
<v-text-field
v-model="customFrom"
type="date"
label="С"
variant="outlined"
density="compact"
hide-details
class="period-date"
data-testid="period-from"
aria-label="Период с"
/>
<span class="period-dash"></span>
<v-text-field
v-model="customTo"
type="date"
label="По"
variant="outlined"
density="compact"
hide-details
class="period-date"
data-testid="period-to"
aria-label="Период по"
/>
</template>
</div>
</template>
<style scoped>
.period-picker {
display: inline-flex;
align-items: center;
gap: 6px;
}
.period-select {
min-width: 180px;
max-width: 240px;
}
.period-date {
width: 140px;
}
.period-dash {
color: #66635c;
font-size: 14px;
flex-shrink: 0;
}
</style>

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