Compare commits

...

43 Commits

Author SHA1 Message Date
Дмитрий 612bf71928 fix(privacy): срезаем канальный префикс поставщика B<N>_ из клиентских выдач
Клиент не должен видеть внутреннюю схему каналов поставщика (B1_/B2_/B6_…).
Фронт срезал префикс только на экране, но API сделок, публичный API тенанта и
экспорт CSV/XLSX отдавали сырое имя проекта — префикс утекал клиенту.

- new App\Support\SupplierProjectName::strip() (regex ^B\d+_) — серверный срез
- применён в DealController (SPA), V1\DealsController (публичный API), DealExportController (экспорт)
- фронтовый stripChannelPrefix расширен ^B[123]_ -> ^B\d+_ (закрывает B6/B8)
- убрано имя поставщика из комментариев клиентского фронта; admin-строки -> crm.lead.store
- phpstan-baseline перегенерён (сдвиг счётчиков Pest-ложняков + убран устаревший ignore)
- тесты: unit 6 + feature x3 (RED->GREEN), Pest 68/68, Vitest 9/9, Pint clean, stan 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:47:49 +03:00
Дмитрий f07897a0f7 feat(external): на плитке внешних сервисов — и баланс, и статус
Мини-плашка теперь показывает деньги (или —) И слово статуса (жив/выключено/ok)
рядом, как в детализации. Убрана мёртвая serviceValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:35:56 +03:00
Дмитрий f6760b74ff fix(external): Баланс = только деньги; живость — в колонку Статус
Баланс/статус различаем по типу сервиса (LIVENESS_ONLY_KEYS), не по null-балансу
(денежный Поставщик с сорванным автологином больше не показывается «выключено»).
Мини-плитка переименована «Балансы сервисов»→«Внешние сервисы».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:25:15 +03:00
Дмитрий 7f5902d610 feat(external): фронт — плитка «Внешние сервисы» (баланс + живость)
Статус-текст для сервисов без денежного баланса (жив/не отвечает/выключено),
метки и иконки почты/ЮKassa/Jivo/капчи, обновлённые подписи плитки и дрилла.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:28:12 +03:00
Дмитрий 7230e86f36 feat(external): email-алерт при переходе внешнего сервиса в красный
Edge-trigger: одно письмо на ops-адрес при первом покраснении (баланс на
исходе или сервис упал); повторное красное не спамит. Mailable + blade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:25:09 +03:00
Дмитрий 8e40e1e76b feat(external): джоба пишет строки живости внешних сервисов
Стабы fakeProvider/fakeProbe вынесены в tests/Pest.php (нужны в двух файлах).
Старые балансовые тесты отключают пробы живости (useLivenessProbes([])).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:20:41 +03:00
Дмитрий b6654f8a9e feat(external): CaptchaLivenessProbe — статус капчи
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:15:20 +03:00
Дмитрий cedbb9de92 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 08:13:08 +03:00
Дмитрий 41fb1e9d02 feat(external): JivoLivenessProbe — живость чата
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:09:56 +03:00
Дмитрий 0ef791b6e2 feat(external): YooKassaLivenessProbe — живость платёжного шлюза
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:55:42 +03:00
Дмитрий 5e8e58d1d1 feat(external): SmtpLivenessProbe — живость почты Yandex 360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:50:16 +03:00
Дмитрий 9fd4459e2f feat(external): DTO LivenessReading + интерфейс LivenessProbe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:43:05 +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
165 changed files with 16158 additions and 498 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',
+12 -2
View File
@@ -119,7 +119,14 @@ 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'''
]
regexTarget = "match"
regexes = [
@@ -167,5 +174,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'''
]
+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;
}
}
@@ -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(),
]);
}
@@ -13,6 +13,7 @@ use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use App\Support\SupplierProjectName;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -211,7 +212,7 @@ class DealController extends Controller
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => $d->project?->name,
'project_name' => SupplierProjectName::strip($d->project?->name),
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
@@ -308,7 +309,7 @@ class DealController extends Controller
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => $deal->project?->name,
'project_name' => SupplierProjectName::strip($deal->project?->name),
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -121,7 +122,7 @@ class DealExportController extends Controller
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
@@ -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,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,378 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
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)
*
* Менеджер видит только своих клиентов (через 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)
*/
class SalesClientsController extends Controller
{
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);
$data = $rows->map(function (object $row) use ($metrics, $period): array {
$tenantId = (int) $row->tenant_id;
// 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-логику:
// trial > suspended > overdue > active > else raw status.
$derivedStatus = 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,
};
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' => null, // Phase 3: tariff engine
];
})->all();
return response()->json(['data' => $data]);
}
/**
* Карточка клиента.
*
* 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);
// 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' => null, // Phase 3: tariff engine
],
'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;
}
}
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Support\SupplierProjectName;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -87,7 +88,7 @@ class DealsController extends Controller
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => $d->project?->name,
'project' => SupplierProjectName::strip($d->project?->name),
])->all(),
'next_cursor' => $next,
]);
@@ -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(
+85
View File
@@ -4,17 +4,24 @@ declare(strict_types=1);
namespace App\Jobs\External;
use App\Mail\ExternalServiceDownMail;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\JivoLivenessProbe;
use App\Services\External\LivenessProbe;
use App\Services\External\SmtpLivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use App\Services\External\YooKassaLivenessProbe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
@@ -40,8 +47,46 @@ class RefreshExternalBalancesJob implements ShouldQueue
];
}
/** @var array<int,LivenessProbe>|null Подмена списка проб в тестах; null → дефолт. */
private static ?array $livenessOverride = null;
/** @param array<int,LivenessProbe> $probes */
public static function useLivenessProbes(array $probes): void
{
self::$livenessOverride = $probes;
}
public static function resetLivenessProbes(): void
{
self::$livenessOverride = null;
}
/** @return array<int,LivenessProbe> */
private function livenessProbes(): array
{
if (self::$livenessOverride !== null) {
return self::$livenessOverride;
}
return [
app(SmtpLivenessProbe::class),
app(YooKassaLivenessProbe::class),
app(JivoLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
public function handle(): void
{
// Прежние цвета (для edge-trigger алерта): service_key => light.
$priorLights = DB::connection(self::DB_CONNECTION)
->table('external_service_balances')
->pluck('light', 'service_key')
->all();
/** @var array<int,array{key:string,detail:string}> $newlyRed */
$newlyRed = [];
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
@@ -85,6 +130,46 @@ class RefreshExternalBalancesJob implements ShouldQueue
'updated_at' => now(),
],
);
if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе'];
}
}
// Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу.
foreach ($this->livenessProbes() as $probe) {
$reading = $probe->check(); // не бросает
$key = $probe->serviceKey();
// Свежий builder на каждую итерацию (как в балансовом цикле).
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => null,
'currency' => 'RUB',
'daily_spend_estimate' => null,
'days_left' => null,
'light' => $reading->light,
// ok=true у green/red (статус определённый), false у grey (не смогли/не применимо).
'ok' => $reading->light !== 'grey',
// error несёт человеческую подпись для red/grey (для плитки); у green — null.
'error' => $reading->light === 'green' ? null : $reading->detail,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => $reading->detail];
}
}
// Edge-trigger: одно письмо, если появились новые красные сервисы.
if ($newlyRed !== []) {
$to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now()));
}
}
@@ -394,12 +394,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 +421,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 +455,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 +491,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 +500,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 +520,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 +543,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 +562,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',
+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,
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-алерт: один или несколько внешних сервисов впервые перешли в «красный»
* (упали или деньги на исходе). Шлётся из RefreshExternalBalancesJob по edge-trigger.
*/
final class ExternalServiceDownMail extends Mailable
{
use Queueable;
use SerializesModels;
/** @param array<int,array{key:string,detail:string}> $services */
public function __construct(
public readonly array $services,
public readonly CarbonInterface $checkedAt,
) {}
public function envelope(): Envelope
{
$names = implode(', ', array_map(fn ($s) => $s['key'], $this->services));
return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names);
}
public function content(): Content
{
return new Content(view: 'emails.external_service_down');
}
}
+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'),
];
}
}
+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');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?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: 'topup_step' | 'percent_oborot' | 'fix_per_client'
* 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');
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Models;
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
*/
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');
}
}
@@ -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 {}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость капчи (Yandex SmartCaptcha). Оплата за вызов активно НЕ пингуем.
* driver=null/'' (выключена) grey «выключена»; иначе green «включена».
*/
class CaptchaLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'captcha';
}
public function check(): LivenessReading
{
$driver = (string) config('services.captcha.driver', 'null');
if ($driver === '' || $driver === 'null') {
return LivenessReading::unknown('captcha', 'выключена');
}
return LivenessReading::alive('captcha', 'включена');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Живость чата JivoSite: HTTP GET виджет-скрипта по widget_id. 200 жив.
* Нет widget_id grey. Денег не тратит (публичный статик).
*/
class JivoLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'jivosite';
}
public function check(): LivenessReading
{
$id = (string) config('services.jivosite.widget_id');
if ($id === '') {
return LivenessReading::unknown('jivosite', 'widget_id не задан');
}
try {
$tpl = (string) config('services.jivosite.widget_url_template', 'https://code.jivo.ru/widget/{id}');
$url = str_replace('{id}', $id, $tpl);
$resp = Http::timeout(5)->get($url);
if ($resp->ok()) {
return LivenessReading::alive('jivosite', 'виджет доступен');
}
return LivenessReading::down('jivosite', 'HTTP '.$resp->status());
} catch (\Throwable $e) {
return LivenessReading::down('jivosite', $e->getMessage());
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Переходник «жив ли внешний сервис» (для сервисов без денежного баланса).
* check() НЕ бросает любую ошибку заворачивает в LivenessReading::down()/unknown().
* Параллель к BalanceProvider (тот про деньги, этот про доступность).
*/
interface LivenessProbe
{
/** email | yookassa | jivosite | captcha */
public function serviceKey(): string;
public function check(): LivenessReading;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Carbon;
/**
* Снимок «жив ли сервис» результат LivenessProbe. Иммутабельный.
* Проба НЕ бросает наружу: недоступность заворачивает в self::down(),
* невозможность проверить (сервис выключен) в self::unknown().
*
* light: green = ответил ок; red = определённо не отвечает; grey = проверить нельзя.
*/
final class LivenessReading
{
public function __construct(
public readonly string $serviceKey,
public readonly string $light, // green|red|grey
public readonly string $detail, // человеческая подпись: «жив» / «не отвечает: timeout» / «выключена»
public readonly Carbon $checkedAt,
) {}
public static function alive(string $key, string $detail = 'жив'): self
{
return new self($key, 'green', $detail, now());
}
public static function down(string $key, string $detail): self
{
return new self($key, 'red', mb_substr($detail, 0, 500), now());
}
public static function unknown(string $key, string $detail): self
{
return new self($key, 'grey', mb_substr($detail, 0, 500), now());
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->connector = $connector;
}
public function serviceKey(): string
{
return 'email';
}
public function check(): LivenessReading
{
try {
$banner = ($this->connector ?? $this->defaultConnector())();
if (! str_starts_with(ltrim($banner), '220')) {
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
}
return LivenessReading::alive('email', 'SMTP отвечает');
} catch (\Throwable $e) {
return LivenessReading::down('email', $e->getMessage());
}
}
/** @return callable():string */
private function defaultConnector(): callable
{
return function (): string {
$host = (string) config('services.smtp_probe.host');
$port = (int) config('services.smtp_probe.port');
$timeout = (int) config('services.smtp_probe.timeout', 5);
// 465 — implicit TLS; ssl:// нужен на connect.
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
if ($fp === false) {
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
}
try {
stream_set_timeout($fp, $timeout);
$line = fgets($fp, 512);
return $line === false ? '' : $line;
} finally {
fclose($fp);
}
};
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Живость платёжного шлюза ЮKassa: GET /v3/me под Basic-авторизацией магазина
* (shopId + секретный ключ). 200 жив. Денег не тратит (справочный эндпоинт).
* Нет ключей grey (нечего проверять).
*/
class YooKassaLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'yookassa';
}
public function check(): LivenessReading
{
$shopId = (string) config('services.yookassa.shop_id');
$secret = (string) config('services.yookassa.secret_key');
if ($shopId === '' || $secret === '') {
return LivenessReading::unknown('yookassa', 'ключи ЮKassa не заданы');
}
try {
$url = rtrim((string) config('services.yookassa.api_url', 'https://api.yookassa.ru/v3'), '/').'/me';
$resp = Http::withBasicAuth($shopId, $secret)->timeout(5)->acceptJson()->get($url);
if ($resp->ok()) {
return LivenessReading::alive('yookassa', 'шлюз отвечает');
}
return LivenessReading::down('yookassa', 'HTTP '.$resp->status());
} catch (\Throwable $e) {
return LivenessReading::down('yookassa', $e->getMessage());
}
}
}
@@ -0,0 +1,145 @@
<?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 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');
}
/**
* Прогноз «запас в днях» для тенанта.
*
* Реиспользует единственный источник истины: 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,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);
}
}
@@ -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++;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилита отображения имён проектов поставщика display-only.
*
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт),
* а не только на фронте иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…».
*
* Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix.
* Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем только вывод.
*/
final class SupplierProjectName
{
/** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */
private const CHANNEL_PREFIX_RE = '/^B\d+_/i';
/**
* Срезает канальный префикс из начала имени проекта.
* null null (не ломаем nullable-контракт API), '' '', остальное без префикса.
*/
public static function strip(?string $name): ?string
{
if ($name === null || $name === '') {
return $name;
}
return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name;
}
}
+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,
],
];
+17
View File
@@ -95,12 +95,21 @@ return [
'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
],
// Healthcheck доступности SMTP (Yandex 360) для плитки внешних сервисов.
// Только connect+баннер, без логина/отправки. Дефолты — под Yandex 360.
'smtp_probe' => [
'host' => env('SMTP_PROBE_HOST', env('MAIL_HOST', 'smtp.yandex.ru')),
'port' => (int) env('SMTP_PROBE_PORT', 465),
'timeout' => (int) env('SMTP_PROBE_TIMEOUT', 5),
],
// G7-A: клиентская «Помощь».
'support' => [
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
],
'jivosite' => [
'widget_id' => env('JIVO_WIDGET_ID'),
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
@@ -108,10 +117,18 @@ return [
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
// 77.75.154.128/25,77.75.156.11,77.75.156.35,2a02:5180::/32.
'yookassa' => [
'shop_id' => env('YOOKASSA_SHOP_ID'),
'secret_key' => env('YOOKASSA_SECRET_KEY'),
'api_url' => env('YOOKASSA_API_URL', 'https://api.yookassa.ru/v3'),
'webhook_ip_allowlist' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('YOOKASSA_WEBHOOK_IPS', '')),
))),
],
// Куда слать алерты о недоступности/исходе денег внешних сервисов.
'monitoring' => [
'alert_email' => env('MONITORING_ALERT_EMAIL', env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru')),
],
];
@@ -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');
}
};
+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"
}
}
+115 -13
View File
@@ -73,13 +73,7 @@ parameters:
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
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
@@ -657,7 +651,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 13
count: 15
path: tests/Feature/AdminTenantsIndexTest.php
-
@@ -675,7 +669,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 14
count: 15
path: tests/Feature/Api/V1/PublicDealsApiTest.php
-
@@ -1044,6 +1038,18 @@ parameters:
count: 6
path: tests/Feature/Auth/UpdateProfileTest.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\:\:putJson\(\)\.$#'
identifier: method.notFound
@@ -1116,6 +1122,30 @@ parameters:
count: 1
path: tests/Feature/Billing/BillingPreflightInitialSweepTest.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\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
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: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
identifier: property.notFound
@@ -1473,7 +1503,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 7
count: 9
path: tests/Feature/DealExportTest.php
-
@@ -1491,7 +1521,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
identifier: method.notFound
count: 5
count: 6
path: tests/Feature/DealExportTest.php
-
@@ -1527,7 +1557,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 45
count: 47
path: tests/Feature/DealIndexTest.php
-
@@ -1545,7 +1575,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 31
count: 32
path: tests/Feature/DealIndexTest.php
-
@@ -2730,6 +2760,78 @@ parameters:
count: 2
path: tests/Feature/SaasAdminMiddlewareTest.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\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 5
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: '#^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/SalesClientCardTest.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\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.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/SalesGuardTest.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\:\:artisan\(\)\.$#'
identifier: method.notFound
+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 (мгновенное зачисление);
+144
View File
@@ -0,0 +1,144 @@
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: 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;
}
+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>
@@ -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>
@@ -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>
+10 -9
View File
@@ -1,19 +1,20 @@
/**
* Утилиты отображения имён проектов crm.bp.
* Утилиты отображения имён проектов.
*
* Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
* (B1_/B2_/B3_ — три разных базы лидов). В UI Лидерры префикс — шум:
* пользователю интересен сам проект, а не канал.
* Внешний источник префиксует имена проектов кодом канала-провайдера
* (B1_/B2_/B3_/B6_/B8_/B<N>_ — разные базы лидов). В UI Лидерры префикс — шум
* и лишний намёк на внутреннюю кухню: пользователю интересен сам проект, а не канал.
*
* Трансформация — **display-only**: данные в БД (`supplier_projects.name`)
* не трогаем, фильтрация/поиск/маппинг идёт по сырому имени и `id`.
* Трансформация — **display-only**: данные в БД не трогаем,
* фильтрация/поиск/маппинг идёт по сырому имени и `id`.
* Серверный аналог для API/экспорта — App\Support\SupplierProjectName::strip().
*/
const CHANNEL_PREFIX_RE = /^B[123]_/i;
const CHANNEL_PREFIX_RE = /^B\d+_/i;
/**
* Убирает префикс B1_/B2_/B3_ из начала имени проекта (case-insensitive).
* Префикс внутри строки и другие буквы (B0/B4/Bx) не трогает.
* Убирает канальный префикс B<цифры>_ из начала имени проекта (case-insensitive):
* B1_/B2_/B3_/B6_/B8_/B10_… Букву (BX_) и префикс внутри строки не трогает.
* null/undefined/'' -> ''.
*/
export function stripChannelPrefix(name: string | null | undefined): string {
+1
View File
@@ -29,6 +29,7 @@ const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/admin/invoices' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
/**
* Layout портала отдела продаж — sidebar #012019 с брендом «Лидерра / ОТДЕЛ ПРОДАЖ»,
* двумя группами навигации (Менеджер / Начальник), topbar с PeriodPicker.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_sales.html #screen-portal.
* Структурная модель: AdminLayout.vue.
*
* Секция «Начальник» отображается только при salesAuth.isHead === true.
*/
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { useSalesAuthStore } from '../stores/salesAuth';
import PeriodPicker from '../components/sales/PeriodPicker.vue';
interface NavItem {
title: string;
icon: string;
to: string;
}
const managerNav: NavItem[] = [
{ title: 'Сводка', icon: 'mdi-view-dashboard-outline', to: '/sales' },
{ title: 'Мои клиенты', icon: 'mdi-account-group-outline', to: '/sales/clients' },
{ title: 'Привязать клиента', icon: 'mdi-account-search-outline', to: '/sales/attach' },
{ title: 'Мой доход', icon: 'mdi-currency-rub', to: '/sales/income' },
];
const bossNav: NavItem[] = [
{ title: 'Сводка отдела', icon: 'mdi-chart-line', to: '/sales/boss' },
{ title: 'Результативность', icon: 'mdi-account-check-outline', to: '/sales/performance' },
{ title: 'Тарифы менеджеров', icon: 'mdi-tag-arrow-right', to: '/sales/tariffs' },
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/sales/invoices' },
{ title: 'Заявки на привязку', icon: 'mdi-file-check-outline', to: '/sales/requests' },
{ title: 'Выплаты', icon: 'mdi-credit-card-outline', to: '/sales/payouts' },
{ title: 'Менеджеры', icon: 'mdi-account-multiple-outline', to: '/sales/managers' },
];
const route = useRoute();
const router = useRouter();
const salesAuth = useSalesAuthStore();
const roleName = computed(() => (salesAuth.isHead ? 'Начальник' : 'Менеджер'));
const userInitials = computed(() => {
const u = salesAuth.user;
if (!u) return 'МП';
const parts = u.name.split(' ').filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return u.name.slice(0, 2).toUpperCase();
});
const currentPageTitle = computed(() => {
const all = [...managerNav, ...bossNav];
return all.find((i) => route.path === i.to || route.path.startsWith(i.to + '/'))?.title ?? 'Продажи';
});
function isActive(to: string): boolean {
if (to === '/sales') return route.path === '/sales';
return route.path.startsWith(to);
}
async function handleLogout() {
await salesAuth.logout();
await router.push('/sales/login');
}
</script>
<template>
<v-app>
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="sales-drawer">
<!-- Brand block -->
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
<path
d="M16 14 L16 34 L32 34"
stroke="#012019"
stroke-width="4.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
</svg>
</span>
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
</div>
<div class="brand-sub">ОТДЕЛ ПРОДАЖ</div>
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Навигация отдела продаж">
<!-- МЕНЕДЖЕР group -->
<div class="nav-eyebrow">Менеджер</div>
<v-list-item
v-for="item in managerNav"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:active="isActive(item.to)"
rounded="lg"
class="nav-item"
:exact="item.to === '/sales'"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
<!-- НАЧАЛЬНИК group only for head role -->
<template v-if="salesAuth.isHead">
<div class="nav-eyebrow nav-eyebrow--boss">Начальник</div>
<v-list-item
v-for="item in bossNav"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:active="isActive(item.to)"
rounded="lg"
class="nav-item"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
<v-app-bar :elevation="0" color="surface" :height="56" class="sales-topbar">
<div class="crumb">
<span class="text-medium-emphasis">Продажи</span>
<v-icon size="14" class="mx-1">mdi-chevron-right</v-icon>
<strong>{{ currentPageTitle }}</strong>
</div>
<v-spacer />
<!-- Period picker -->
<PeriodPicker class="mr-3" />
<!-- Role chip -->
<v-chip
:color="salesAuth.isHead ? '#7B4D00' : '#084635'"
:style="{
background: salesAuth.isHead ? '#FFF4DD' : '#E1EEEA',
color: salesAuth.isHead ? '#7B4D00' : '#084635',
}"
size="small"
class="role-chip mr-2"
label
>
{{ roleName.toUpperCase() }}
</v-chip>
<!-- User menu -->
<v-menu offset="8">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
size="small"
class="user-chip mr-2"
aria-label="Меню пользователя"
>
<v-avatar size="24" color="#0F6E56" class="mr-2">
<span class="text-caption" style="color: #fff; font-size: 10px">{{ userInitials }}</span>
</v-avatar>
<span class="text-body-2">{{ salesAuth.user?.name ?? '' }}</span>
</v-btn>
</template>
<v-list density="compact" min-width="200">
<v-list-item v-if="salesAuth.user" :title="salesAuth.user.email" disabled />
<v-divider v-if="salesAuth.user" />
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
</v-list>
</v-menu>
</v-app-bar>
<v-main class="sales-main">
<RouterView />
</v-main>
</v-app>
</template>
<style scoped>
.sales-drawer {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.brand-block {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 20px 4px;
}
.brand-mark {
width: 24px;
height: 24px;
border-radius: 5px;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-text {
font-weight: 600;
font-size: 16px;
color: #fff;
letter-spacing: -0.01em;
}
.brand-dot {
color: #32c8a9;
}
.brand-sub {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: 0.08em;
color: #32c8a9;
padding: 0 20px 14px;
text-transform: uppercase;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.nav-eyebrow {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.38);
padding: 14px 16px 6px;
text-transform: uppercase;
}
.nav-eyebrow--boss {
margin-top: 4px;
}
.sales-topbar {
border-bottom: 1px solid #d9d5cd !important;
}
.crumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-left: 8px;
}
.role-chip {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px !important;
letter-spacing: 0.04em;
font-weight: 600;
}
.user-chip {
text-transform: none;
border-color: #d9d5cd !important;
}
.sales-main {
background: #f6f3ec;
}
</style>
+135 -1
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useSalesAuthStore } from '../stores/salesAuth';
/**
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
@@ -26,6 +27,10 @@ declare module 'vue-router' {
devIndex?: number;
devLabel?: string;
transition?: string;
/** Портал продаж: требует salesAuth.token */
salesAuth?: boolean;
/** Портал продаж: только для начальника (role==='head') */
salesBossOnly?: boolean;
}
}
@@ -202,7 +207,13 @@ const routes: RouteRecordRaw[] = [
path: '/admin/dashboard',
name: 'admin-dashboard',
component: () => import('../views/admin/AdminDashboardView.vue'),
meta: { layout: 'admin', title: 'Командный центр', requiresAuth: true, devIndex: 20, devLabel: 'Admin Dashboard' },
meta: {
layout: 'admin',
title: 'Командный центр',
requiresAuth: true,
devIndex: 20,
devLabel: 'Admin Dashboard',
},
},
{
path: '/admin/tenants',
@@ -222,6 +233,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
},
{
path: '/admin/invoices',
name: 'admin-invoices',
component: () => import('../views/admin/AdminInvoicesView.vue'),
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
},
{
path: '/admin/leads',
name: 'admin-leads',
@@ -337,6 +354,103 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Помощь',
},
},
// ─── Портал отдела продаж (/sales) ───────────────────────────────────────
// Три группы:
// 1. /sales/login — страница входа (без guard'а).
// 2. Маршруты менеджера — требуют salesAuth.token.
// 3. Маршруты начальника — требуют salesAuth.role === 'head'.
// Guard реализован в beforeEach ниже через meta.salesAuth / meta.salesBossOnly.
{
path: '/sales/login',
name: 'sales-login',
component: () => import('../views/sales/SalesLoginView.vue'),
meta: { layout: 'sales-login', title: 'Вход — Отдел продаж' },
},
{
path: '/sales',
name: 'sales-overview',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Сводка', salesAuth: true },
props: { title: 'Сводка' },
},
{
path: '/sales/clients',
name: 'sales-clients',
component: () => import('../views/sales/SalesClientsView.vue'),
meta: { layout: 'sales', title: 'Мои клиенты', salesAuth: true },
},
{
path: '/sales/clients/:id',
name: 'sales-client-detail',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Карточка клиента', salesAuth: true },
props: { title: 'Карточка клиента' },
},
{
path: '/sales/attach',
name: 'sales-attach',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Привязать клиента', salesAuth: true },
props: { title: 'Привязать клиента' },
},
{
path: '/sales/income',
name: 'sales-income',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Мой доход', salesAuth: true },
props: { title: 'Мой доход' },
},
// Маршруты начальника (boss-only)
{
path: '/sales/boss',
name: 'sales-boss',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Сводка отдела', salesAuth: true, salesBossOnly: true },
props: { title: 'Сводка отдела' },
},
{
path: '/sales/performance',
name: 'sales-performance',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Результативность', salesAuth: true, salesBossOnly: true },
props: { title: 'Результативность' },
},
{
path: '/sales/tariffs',
name: 'sales-tariffs',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Тарифы менеджеров', salesAuth: true, salesBossOnly: true },
props: { title: 'Тарифы менеджеров' },
},
{
path: '/sales/invoices',
name: 'sales-invoices',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Счета', salesAuth: true, salesBossOnly: true },
props: { title: 'Счета (оплата)' },
},
{
path: '/sales/requests',
name: 'sales-requests',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Заявки на привязку', salesAuth: true, salesBossOnly: true },
props: { title: 'Заявки на привязку' },
},
{
path: '/sales/payouts',
name: 'sales-payouts',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Выплаты', salesAuth: true, salesBossOnly: true },
props: { title: 'Выплаты менеджерам' },
},
{
path: '/sales/managers',
name: 'sales-managers',
component: () => import('../views/sales/SalesStubView.vue'),
meta: { layout: 'sales', title: 'Менеджеры', salesAuth: true, salesBossOnly: true },
props: { title: 'Менеджеры' },
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
@@ -387,5 +501,25 @@ router.beforeEach(async (to) => {
return { path: '/dashboard' };
}
// ─── Guard для портала отдела продаж ─────────────────────────────────────
if (to.meta.salesAuth) {
const salesAuth = useSalesAuthStore();
// Cold start: если токен есть в localStorage, но user не загружен — грузим.
if (salesAuth.token && !salesAuth.user) {
await salesAuth.fetchMe();
}
// Нет токена / нет user → /sales/login
if (!salesAuth.isAuthenticated) {
return { path: '/sales/login', query: { redirect: to.fullPath } };
}
// Boss-only маршрут: только начальник
if (to.meta.salesBossOnly && !salesAuth.isHead) {
return { path: '/sales' };
}
}
return true;
});
+120
View File
@@ -0,0 +1,120 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import * as salesApi from '../api/sales';
import type { SalesUser } from '../api/sales';
/**
* Auth-store для портала отдела продаж.
*
* Хранит Bearer-токен в localStorage ('sales_token') — в отличие от
* основного auth (Sanctum SPA cookie), это token-based auth.
*
* Использование:
* const salesAuth = useSalesAuthStore();
* await salesAuth.login(email, password);
* if (salesAuth.isAuthenticated) { ... }
* if (salesAuth.isHead) { // только начальник }
* await salesAuth.logout();
*/
const TOKEN_KEY = 'sales_token';
export const useSalesAuthStore = defineStore('salesAuth', () => {
// Восстанавливаем токен из localStorage при старте.
const token = ref<string | null>(
(() => {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
})(),
);
const user = ref<SalesUser | null>(null);
const loading = ref(false);
// ─── getters ────────────────────────────────────────────────────────────
const isAuthenticated = computed(() => token.value !== null && user.value !== null);
const role = computed(() => user.value?.role ?? null);
const isHead = computed(() => user.value?.role === 'head');
// ─── helpers ─────────────────────────────────────────────────────────────
function persistToken(t: string | null): void {
try {
if (t) {
localStorage.setItem(TOKEN_KEY, t);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch {
// silent — localStorage может быть недоступен
}
token.value = t;
}
// ─── actions ─────────────────────────────────────────────────────────────
/**
* Войти в портал продаж.
* Сохраняет токен + устанавливает user из ответа.
*/
async function login(email: string, password: string): Promise<void> {
loading.value = true;
try {
const response = await salesApi.salesLogin(email, password);
persistToken(response.token);
user.value = response.user;
} finally {
loading.value = false;
}
}
/**
* Восстановить сессию при cold start (если токен есть в localStorage).
* Возвращает user или null (без throw при 401).
*/
async function fetchMe(): Promise<SalesUser | null> {
if (!token.value) return null;
try {
const fetched = await salesApi.salesMe();
user.value = fetched;
return fetched;
} catch {
// 401 → токен устарел, очищаем
persistToken(null);
user.value = null;
return null;
}
}
/**
* Выйти. Токен удаляется локально в любом случае.
*/
async function logout(): Promise<void> {
try {
await salesApi.salesLogout();
} catch {
// ignore
} finally {
persistToken(null);
user.value = null;
}
}
return {
token,
user,
loading,
isAuthenticated,
role,
isHead,
login,
fetchMe,
logout,
};
});
+81
View File
@@ -0,0 +1,81 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
/**
* Store периода для портала отдела продаж.
*
* kind:
* 'this' — текущий месяц
* 'prev' — прошлый месяц
* 'prev2' — позапрошлый месяц
* 'custom' — произвольный период (from/to обязательны)
*
* Использование:
* const period = useSalesPeriodStore();
* period.setPeriod({ kind: 'prev' });
* period.setPeriod({ kind: 'custom', from: '2026-05-01', to: '2026-05-31' });
* const params = period.queryParams; // { period: 'prev' } или { period: 'custom', from, to }
*/
export type PeriodKind = 'this' | 'prev' | 'prev2' | 'custom';
export interface PeriodState {
kind: PeriodKind;
from?: string;
to?: string;
}
export interface PeriodQueryParams {
period: PeriodKind;
from?: string;
to?: string;
}
export const useSalesPeriodStore = defineStore('salesPeriod', () => {
const kind = ref<PeriodKind>('this');
const from = ref<string | undefined>(undefined);
const to = ref<string | undefined>(undefined);
// ─── getters ─────────────────────────────────────────────────────────────
/** Параметры для API-запросов с фильтрацией по периоду. */
const queryParams = computed<PeriodQueryParams>(() => {
if (kind.value === 'custom') {
return { period: 'custom', from: from.value, to: to.value };
}
return { period: kind.value };
});
/** Человекочитаемый ярлык текущего периода для UI. */
const label = computed<string>(() => {
const labels: Record<PeriodKind, string> = {
this: 'Этот месяц',
prev: 'Прошлый месяц',
prev2: 'Позапрошлый месяц',
custom: from.value && to.value ? `${from.value}${to.value}` : 'Произвольный период',
};
return labels[kind.value];
});
// ─── actions ─────────────────────────────────────────────────────────────
function setPeriod(payload: PeriodState): void {
kind.value = payload.kind;
if (payload.kind === 'custom') {
from.value = payload.from;
to.value = payload.to;
} else {
from.value = undefined;
to.value = undefined;
}
}
return {
kind,
from,
to,
queryParams,
label,
setPeriod,
};
});
+22 -5
View File
@@ -9,7 +9,7 @@
* Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
* TopupDialog «Пополнить баланс» — Task 5 (E1).
*/
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, nextTick } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
@@ -21,7 +21,7 @@ import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
import { useTenantStore } from '../stores/tenantStore';
const activeView = ref<'overview' | 'charges'>('overview');
const activeView = ref<'overview' | 'charges' | 'invoices'>('overview');
const tenant = useTenantStore();
const wallet = ref<Wallet | null>(null);
@@ -32,6 +32,9 @@ const topupSnackbar = ref(false);
// Возврат с платёжной страницы шлюза (?topup=return): баланс зачислится по webhook.
const paymentReturn = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const invoicesTableRef = ref<InstanceType<typeof InvoicesTable> | null>(null);
const invoiceSnackbar = ref(false);
const invoiceMsg = ref('');
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
@@ -65,6 +68,16 @@ async function onTopupSuccess(): Promise<void> {
txTableRef.value?.refresh();
}
async function onInvoiced(invoiceNumber: string): Promise<void> {
// Счёт выставлен — переключаемся на вкладку «Счета», обновляем список и тост.
topupOpen.value = false;
invoiceMsg.value = `Счёт ${invoiceNumber} сформирован. Файл открыт в новой вкладке.`;
invoiceSnackbar.value = true;
activeView.value = 'invoices';
await nextTick();
await invoicesTableRef.value?.load();
}
onMounted(() => {
paymentReturn.value = new URLSearchParams(window.location.search).get('topup') === 'return';
void loadWallet();
@@ -105,6 +118,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
<v-tabs v-model="activeView" color="primary" class="mt-4">
<v-tab value="overview">Обзор</v-tab>
<v-tab value="charges">Списания</v-tab>
<v-tab value="invoices">Счета</v-tab>
</v-tabs>
<v-tabs-window v-model="activeView">
@@ -132,19 +146,22 @@ defineExpose({ loadWallet, wallet, topupOpen });
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
</template>
</v-tabs-window-item>
<v-tabs-window-item value="charges">
<ChargesTab />
</v-tabs-window-item>
<v-tabs-window-item value="invoices">
<InvoicesTable ref="invoicesTableRef" />
</v-tabs-window-item>
</v-tabs-window>
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" @invoiced="onInvoiced" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000"> Баланс пополнен. </v-snackbar>
<v-snackbar v-model="invoiceSnackbar" color="success" :timeout="6000">{{ invoiceMsg }}</v-snackbar>
</v-container>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Страница «Сделки» — реестр лидов, поставленных crm.bp (редизайн 2026-05-17).
* Страница «Сделки» — реестр лидов от поставщика (редизайн 2026-05-17).
*
* Лиды поступают ТОЛЬКО от поставщика — ручного создания и корзины нет.
* Фильтрация (телефон/Статус/Проект + диапазон дат поставки) и пагинация —
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
* Импорт данных — загрузка CSV исторических лидов от поставщика (ТЗ §6).
*
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
* Неизвестные статусы маппятся через UnknownStatusesDialog.
@@ -103,11 +103,19 @@ const SERVICE_LABELS: Record<string, string> = {
dadata: 'DaData',
supplier: 'Поставщик',
yandex_cloud: 'Yandex Cloud',
email: 'Почта',
yookassa: 'ЮKassa',
jivosite: 'JivoSite',
captcha: 'Капча',
};
const SERVICE_ICONS: Record<string, string> = {
dadata: '🧭',
supplier: '📦',
yandex_cloud: '☁️',
email: '✉️',
yookassa: '💳',
jivosite: '💬',
captcha: '🛡',
};
function serviceLabel(key: string): string {
@@ -122,6 +130,33 @@ function daysLeftLabel(days: number | null): string {
return days === null ? '—' : `~${days} дн.`;
}
/** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */
const LIVENESS_ONLY_KEYS = new Set(['email', 'yookassa', 'jivosite', 'captcha']);
function isLivenessOnly(key: string): boolean {
return LIVENESS_ONLY_KEYS.has(key);
}
/** Слово о доступности сервиса по цвету светофора. */
function livenessWord(light: string): string {
if (light === 'green') return 'жив';
if (light === 'red') return 'не отвечает';
return 'выключено'; // grey
}
type ServiceRow = { service_key: string; balance_amount: string | null; light: string; ok: boolean };
/** Колонка «Баланс» (и плитка, и детализация) — ТОЛЬКО деньги; у сервисов-живости всегда «—». */
function serviceMoney(s: ServiceRow): string {
if (isLivenessOnly(s.service_key)) return '—';
return s.ok && s.balance_amount !== null ? rub(s.balance_amount) : '—';
}
/** Колонка «Статус» в детализации — доступность/здоровье сервиса. */
function serviceStatus(s: ServiceRow): string {
if (isLivenessOnly(s.service_key)) return livenessWord(s.light);
return s.ok ? 'ok' : 'не удалось обновить';
}
/** Подпись светофора Клиентов на плитке. */
function clientsLightLabel(): string {
const d = summary.value?.clients.dormant ?? 0;
@@ -520,7 +555,7 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance,
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico">💳</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Балансы сервисов</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Внешние сервисы</span>
<v-chip
:color="lightColor(summary?.balances.light ?? 'grey')"
size="small"
@@ -541,8 +576,9 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance,
<span class="d-flex align-center ga-2">
<span
class="num font-weight-bold"
:class="{ 'text-error': s.light === 'red' }"
>{{ s.ok ? rub(s.balance_amount) : 'нет данных' }}</span>
:class="{ 'text-error': !isLivenessOnly(s.service_key) && s.light === 'red' }"
>{{ serviceMoney(s) }}</span>
<span class="text-caption text-medium-emphasis">{{ serviceStatus(s) }}</span>
<v-icon :color="lightColor(s.light)" size="11" icon="mdi-circle" />
</span>
</div>
@@ -870,12 +906,13 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
<!-- DRILL: БАЛАНСЫ СЕРВИСОВ -->
<v-card v-else-if="selected === 'balances'" variant="outlined" class="drill mt-5" data-testid="drill-balances">
<v-card-title class="drill__head">💳 Балансы внешних сервисов детали</v-card-title>
<v-card-title class="drill__head">🌐 Внешние сервисы баланс и доступность</v-card-title>
<v-card-text>
<v-alert variant="tonal" density="compact" class="mb-4" type="info">
Баланс платных сервисов проверяется раз в сутки (06:30 МСК). Светофор: 🔴 мало денег
или хватит меньше 3 дней, 🟡 меньше 7 дней, не удалось обновить.
Кнопка «Пополнить» открывает страницу оплаты сервиса.
Внешние сервисы проверяются раз в сутки (06:30 МСК): у платных остаток денег,
у остальных жив ли сервис. Светофор: 🔴 упал / мало денег / хватит меньше 3 дней,
🟡 меньше 7 дней, не удалось проверить или выключен. При переходе в 🔴 приходит
письмо на ops-адрес.
</v-alert>
<v-table density="compact">
<thead>
@@ -890,10 +927,10 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
<tbody>
<tr v-for="s in balances?.services ?? []" :key="s.service_key">
<td>{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}</td>
<td class="text-right num" :class="{ 'text-error': s.light === 'red' }">
{{ s.ok ? rub(s.balance_amount) : '—' }}
<td class="text-right num" :class="{ 'text-error': s.balance_amount !== null && s.light === 'red' }">
{{ serviceMoney(s) }}
</td>
<td class="text-right num">{{ s.ok ? daysLeftLabel(s.days_left) : '—' }}</td>
<td class="text-right num">{{ s.balance_amount !== null && s.ok ? daysLeftLabel(s.days_left) : '—' }}</td>
<td class="text-center">
<v-chip
:color="lightColor(s.light)"
@@ -901,7 +938,7 @@ v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
variant="tonal"
:title="s.error ?? ''"
>
{{ s.ok ? 'ok' : 'не удалось обновить' }}
{{ serviceStatus(s) }}
</v-chip>
</td>
<td class="text-right">
@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* AdminInvoicesView — SaaS-admin экран «Счета» (Этап 1 «оплата по счёту»).
* Серверная пагинация/поиск/фильтр по статусу. Кнопка «Отметить оплаченным»
* у выставленных счетов открывает диалог подтверждения → markInvoicePaid →
* зачисление баланса + формирование Акта на бэкенде.
*/
import { ref, onMounted, watch } from 'vue';
import {
listAdminInvoices,
markInvoicePaid,
type AdminInvoiceRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
const STATUS_COLORS: Record<string, string> = {
issued: 'info',
paid: 'success',
overdue: 'warning',
cancelled: 'grey',
draft: 'grey',
};
const STATUS_FILTERS = [
{ value: '', title: 'Все статусы' },
{ value: 'issued', title: 'Выставленные' },
{ value: 'paid', title: 'Оплаченные' },
{ value: 'overdue', title: 'Просроченные' },
{ value: 'cancelled', title: 'Отменённые' },
];
const rows = ref<AdminInvoiceRow[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const lastPage = ref(1);
const search = ref('');
const filterStatus = ref('');
const confirmOpen = ref(false);
const confirmRow = ref<AdminInvoiceRow | null>(null);
const marking = ref(false);
const snackbar = ref(false);
const snackMsg = ref('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function statusLabel(s: string): string {
return STATUS_LABELS[s] ?? s;
}
function statusColor(s: string): string {
return STATUS_COLORS[s] ?? 'grey';
}
function formatDate(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' }) : '—';
}
function formatAmount(v: string): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(Number(v));
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const res = await listAdminInvoices({
status: filterStatus.value || undefined,
search: search.value || undefined,
page: page.value,
per_page: perPage.value,
});
rows.value = res.data;
total.value = res.meta.total;
lastPage.value = res.meta.last_page;
} catch (e) {
loadError.value = extractErrorMessage(e, 'Не удалось загрузить счета.');
} finally {
loading.value = false;
}
}
function goPage(p: number): void {
page.value = p;
void load();
}
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void load();
}, 400);
});
watch(filterStatus, () => {
page.value = 1;
void load();
});
function askMarkPaid(row: AdminInvoiceRow): void {
confirmRow.value = row;
confirmOpen.value = true;
}
async function doMarkPaid(): Promise<void> {
if (confirmRow.value === null) return;
marking.value = true;
try {
await markInvoicePaid(confirmRow.value.id);
snackMsg.value = `Счёт ${confirmRow.value.invoice_number} отмечен оплаченным, баланс зачислен.`;
snackbar.value = true;
confirmOpen.value = false;
confirmRow.value = null;
await load();
} catch (e) {
snackMsg.value = extractErrorMessage(e, 'Не удалось отметить оплату.');
snackbar.value = true;
} finally {
marking.value = false;
}
}
onMounted(load);
defineExpose({ rows, page, perPage, total, search, filterStatus, goPage, load });
</script>
<template>
<v-container class="invoices-admin" fluid>
<div class="page-head mb-4">
<h1 class="text-h5 page-title ma-0">Счета</h1>
</div>
<div class="filters mb-4">
<v-text-field
v-model="search"
label="Поиск по номеру, клиенту, плательщику"
density="comfortable"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 420px"
/>
<v-select
v-model="filterStatus"
:items="STATUS_FILTERS"
item-title="title"
item-value="value"
label="Статус"
density="comfortable"
hide-details
style="max-width: 220px"
/>
</div>
<v-card variant="outlined">
<div v-if="loading" class="py-10 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="rows.length === 0" class="py-10 text-center text-medium-emphasis">
Счетов не найдено.
</div>
<v-table v-else>
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Клиент</th>
<th>Плательщик</th>
<th class="text-right">Сумма, </th>
<th>Статус</th>
<th>Оплатить до</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ formatDate(r.issued_at) }}</td>
<td class="num">{{ r.invoice_number }}</td>
<td>{{ r.tenant_name ?? '—' }}</td>
<td>{{ r.payer_name ?? '—' }}</td>
<td class="text-right num">{{ formatAmount(r.amount_total) }}</td>
<td><v-chip :color="statusColor(r.status)" size="small" variant="tonal">{{ statusLabel(r.status) }}</v-chip></td>
<td class="num">{{ formatDate(r.expires_at) }}</td>
<td class="text-right">
<v-btn
v-if="r.status === 'issued' || r.status === 'overdue'"
color="success"
size="small"
variant="flat"
:data-testid="`mark-paid-${r.id}`"
@click="askMarkPaid(r)"
>
Отметить оплаченным
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div v-if="lastPage > 1" class="d-flex justify-center py-4">
<v-pagination
:model-value="page"
:length="lastPage"
:total-visible="7"
@update:model-value="goPage"
/>
</div>
</v-card>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card v-if="confirmRow">
<v-card-title class="text-h6">Подтверждение оплаты</v-card-title>
<v-card-text>
Отметить счёт <b>{{ confirmRow.invoice_number }}</b> на сумму
<b>{{ formatAmount(confirmRow.amount_total) }} </b>
(клиент: {{ confirmRow.tenant_name ?? confirmRow.payer_name ?? '—' }}) как оплаченный?
Баланс клиента будет пополнен, сформируется Акт.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="marking" @click="confirmOpen = false">Отмена</v-btn>
<v-btn color="success" variant="flat" :loading="marking" @click="doMarkPaid">Подтверждаю</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar" :timeout="5000" color="success">{{ snackMsg }}</v-snackbar>
</v-container>
</template>
<style scoped>
.page-title {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.015em;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>
@@ -505,7 +505,7 @@ onMounted(() => {
<v-dialog :model-value="confirmResolveId !== null" max-width="420" @update:model-value="confirmResolveId = null">
<v-card class="pa-2">
<v-card-title class="text-subtitle-1">Закрыть запись очереди?</v-card-title>
<v-card-text>Подтверждаете, что внесли изменения в crm.bp-gr.ru?</v-card-text>
<v-card-text>Подтверждаете, что внесли изменения в кабинете поставщика (crm.lead.store)?</v-card-text>
<v-card-actions class="px-4 pb-3">
<v-spacer />
<v-btn variant="text" @click="confirmResolveId = null">Отмена</v-btn>
@@ -2,7 +2,7 @@
<div class="admin-supplier-projects-view pa-6">
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект на портале и локальные привязки
Все проекты, заведённые у поставщика (crm.lead.store). Удаление снимает проект на портале и локальные привязки
тенантов (каскадом).
</p>
@@ -2,18 +2,21 @@
/**
* Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR.
*
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в
* components/admin/tenants/{TenantsStatsHeader,TenantsFilters,TenantsTable}.
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.)
* остаётся в этом view ради `defineExpose`-контракта, который Vitest тесты
* используют для прямого доступа.
* Масштаб (28.06.2026): серверная пагинация + серверные фильтры (search/статус/тариф).
* Раньше грузили всех разом и фильтровали в браузере — на 1000 клиентов это не
* «смотрибельно» (поиск/чипы видели только первую страницу). Теперь:
* - страница из `limit/offset` (perPage), счётчик `total` с сервера → v-pagination;
* - поиск (org/subdomain/email ILIKE) — серверный, debounce 400мс;
* - статус (производный trial/overdue/active/suspended) и тариф — серверные multi.
* Бэкенд: AdminTenantsController::index (statuses/tariffs/search/limit/offset/total).
*
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся
* в этом view ради `defineExpose`-контракта Vitest-тестов.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
* По схеме v8.7 §3 (tenants table) + ТЗ §22 (админка).
*
* Click по строке → /admin/tenants/{code} (карточка тенанта).
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
@@ -35,34 +38,93 @@ const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyReven
const loading = ref(false);
const fetchError = ref(false);
async function loadTenants() {
const search = ref('');
const filterStatuses = ref<TenantStatus[]>([]);
const filterTariffs = ref<string[]>([]);
const availableTariffs = ref<string[]>([]);
// Серверная пагинация.
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
async function loadTenants(): Promise<void> {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listAdminTenants();
const res = await adminApi.listAdminTenants({
search: search.value.trim(),
statuses: filterStatuses.value.join(','),
tariffs: filterTariffs.value.join(','),
limit: perPage.value,
offset: (page.value - 1) * perPage.value,
});
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
tenantsState.splice(0, tenantsState.length, ...mapped);
total.value = res.total;
stats.total = res.stats.total;
stats.active = res.stats.active;
stats.trial = res.stats.trial;
stats.overdue = res.stats.overdue;
} catch {
fetchError.value = true;
tenantsState.splice(0, tenantsState.length);
} finally {
loading.value = false;
}
}
onMounted(loadTenants);
// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть
// тенантов, поэтому список тарифов нельзя выводить из загруженного набора).
async function loadTariffOptions(): Promise<void> {
try {
const plans = await adminApi.listAdminTariffPlans();
availableTariffs.value = Array.from(new Set(plans.map((p) => p.name))).sort();
} catch {
// дропдаун останется пустым — не критично для основного списка.
}
}
// Поиск — debounce 400мс (планшет: печатает → ищет, без кнопки «Найти»).
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void loadTenants();
}, 400);
});
// Фильтры — сразу перезагрузка с 1-й страницы.
watch(
[filterStatuses, filterTariffs],
() => {
page.value = 1;
void loadTenants();
},
{ deep: true },
);
function goPage(p: number): void {
page.value = p;
void loadTenants();
}
onMounted(() => {
void loadTariffOptions();
void loadTenants();
});
usePolling(loadTenants);
function openTenantDetail(t: AdminTenant) {
function openTenantDetail(t: AdminTenant): void {
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
}
const search = ref('');
const filterStatuses = ref<TenantStatus[]>([]);
const filterTariffs = ref<string[]>([]);
function clearFilters(): void {
filterStatuses.value = [];
filterTariffs.value = [];
}
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
@@ -70,21 +132,14 @@ const impersonationTenant = ref<AdminTenant | null>(null);
const balanceDialogOpen = ref(false);
const balanceTarget = ref<AdminTenant | null>(null);
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
function clearFilters() {
filterStatuses.value = [];
filterTariffs.value = [];
}
const ADMIN_USER_ID = 1;
function openImpersonation(tenant: AdminTenant) {
function openImpersonation(tenant: AdminTenant): void {
impersonationTenant.value = tenant;
impersonationOpen.value = true;
}
function openBalanceDialog(tenant: AdminTenant) {
function openBalanceDialog(tenant: AdminTenant): void {
balanceTarget.value = tenant;
balanceDialogOpen.value = true;
}
@@ -106,22 +161,12 @@ defineExpose({
loading,
fetchError,
loadTenants,
});
const filteredTenants = computed<AdminTenant[]>(() => {
const q = search.value.trim().toLowerCase();
const statuses = new Set(filterStatuses.value);
const tariffs = new Set(filterTariffs.value);
return tenantsState.filter((t) => {
if (statuses.size > 0 && !statuses.has(t.status)) return false;
if (tariffs.size > 0 && !tariffs.has(t.tariff)) return false;
if (q) {
const haystack = `${t.name} ${t.inn} ${t.code}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
search,
page,
perPage,
total,
availableTariffs,
goPage,
});
</script>
@@ -153,12 +198,24 @@ const filteredTenants = computed<AdminTenant[]>(() => {
/>
<TenantsTable
:tenants="filteredTenants"
:tenants="tenantsState"
@row-click="openTenantDetail"
@impersonate="openImpersonation"
@edit-balance="openBalanceDialog"
/>
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
<v-pagination
v-model="page"
:length="totalPages()"
:total-visible="7"
density="compact"
data-testid="tenants-pager"
@update:model-value="goPage"
/>
</div>
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
<TenantBalanceDialog
@@ -276,7 +276,13 @@
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn-toggle
v-model="selectedDays"
multiple
density="comfortable"
class="mt-1 day-toggle"
selected-class="day-active"
>
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
@@ -436,9 +442,9 @@ const reqSaving = ref(false);
const reqGeneralError = ref<string | null>(null);
const subjectTypeItems = [
{ value: 'individual', title: 'Физлицо' },
{ value: 'individual', title: 'Физическое лицо' },
{ value: 'sole_proprietor', title: 'ИП' },
{ value: 'legal_entity', title: 'Юрлицо' },
{ value: 'legal_entity', title: 'Юридическое лицо' },
];
// Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП).
@@ -767,4 +773,12 @@ defineExpose({
border-color: currentColor;
opacity: 1;
}
/* Выбранные дни недели — сплошная зелёная заливка, как в ProjectDetailsDrawer (.pdd-day.active) */
.day-toggle :deep(.v-btn.day-active) {
background-color: #0f6e56;
color: #fff;
}
.day-toggle :deep(.v-btn.day-active .v-btn__overlay) {
opacity: 0;
}
</style>
@@ -0,0 +1,347 @@
<script setup lang="ts">
/**
* Портал продаж → Мои клиенты (Task 1.3).
*
* Таблица клиентов менеджера/начальника. Реализует #page-clients из v8_sales.html.
* Данные: GET /api/sales/clients?period=...&search=...
* Период берётся из salesPeriod store (PeriodPicker уже встроен в SalesLayout topbar).
* При смене периода таблица перегружается автоматически (watch queryParams).
*/
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { listSalesClients, type SalesClientRow } from '../../api/sales';
import { useSalesPeriodStore } from '../../stores/salesPeriod';
import HelpHint from '../../components/sales/HelpHint.vue';
const router = useRouter();
const periodStore = useSalesPeriodStore();
const rows = ref<SalesClientRow[]>([]);
const loading = ref(false);
const fetchError = ref(false);
const search = ref('');
// ─── subject_type labels ──────────────────────────────────────────────────────
const SUBJECT_TYPE_LABELS: Record<string, string> = {
individual: 'Физическое лицо',
sole_proprietor: 'ИП',
legal_entity: 'Юридическое лицо',
};
function subjectLabel(type: string | null): string {
if (!type) return '—';
return SUBJECT_TYPE_LABELS[type] ?? type;
}
// ─── status chips ─────────────────────────────────────────────────────────────
interface StatusMeta {
label: string;
color: string;
variant: 'tonal' | 'flat';
}
const STATUS_META: Record<string, StatusMeta> = {
trial: { label: 'Триал', color: 'blue-grey', variant: 'tonal' },
active: { label: 'Активен', color: 'success', variant: 'tonal' },
overdue: { label: 'Просрочка', color: 'error', variant: 'tonal' },
suspended: { label: 'Приостановлен', color: 'grey', variant: 'tonal' },
};
function statusMeta(s: string): StatusMeta {
return STATUS_META[s] ?? { label: s, color: 'grey', variant: 'tonal' };
}
// ─── formatters ───────────────────────────────────────────────────────────────
function fmtMoney(val: string | number): string {
const n = typeof val === 'string' ? parseFloat(val) : val;
if (isNaN(n)) return '—';
return n.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' ₽';
}
function fmtRunway(days: number | null): string {
if (days === null || days === undefined) return '—';
return days + ' дн.';
}
function fmtActivity(iso: string | null): string {
if (!iso) return '—';
// Format: "28.06 09:41"
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'только что';
if (diffMin < 60) return `${diffMin} мин назад`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч назад`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD} дн назад`;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
return `${dd}.${mm}`;
}
// ─── data loading ─────────────────────────────────────────────────────────────
async function load() {
loading.value = true;
fetchError.value = false;
try {
const params = {
...periodStore.queryParams,
...(search.value ? { search: search.value } : {}),
};
rows.value = await listSalesClients(params);
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
function applySearch() {
void load();
}
// Reload when period changes
watch(() => periodStore.queryParams, load, { deep: true });
onMounted(load);
// ─── row click ────────────────────────────────────────────────────────────────
function openClient(row: SalesClientRow) {
router.push('/sales/clients/' + row.tenant_id);
}
defineExpose({ rows, loading, fetchError, search, load });
</script>
<template>
<v-container fluid class="sales-clients pa-6">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-4 flex-wrap ga-3">
<div>
<h1 class="text-h5 font-weight-bold sc-page-title">Мои клиенты</h1>
</div>
</div>
<!-- Search bar -->
<div class="d-flex align-center ga-3 mb-4 flex-wrap">
<v-text-field
v-model="search"
density="compact"
variant="outlined"
hide-details
placeholder="Название клиента, ИНН…"
prepend-inner-icon="mdi-magnify"
style="max-width: 320px"
data-testid="search-input"
@keyup.enter="applySearch"
/>
<v-btn color="primary" class="text-none" data-testid="apply-search" @click="applySearch"> Найти </v-btn>
</div>
<!-- Error alert -->
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
Не удалось загрузить данные. Попробуйте обновить страницу.
</v-alert>
<!-- Loading skeleton -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
<!-- Table -->
<v-card variant="outlined">
<v-table density="compact" data-testid="clients-table">
<thead>
<tr>
<th>Клиент</th>
<th>
Тип
<HelpHint text="Тип лица клиента: физическое лицо, ИП или юридическое лицо" />
</th>
<th>Активность</th>
<th class="text-right sc-num">Баланс</th>
<th class="text-right sc-num">
Запас
<HelpHint text="На сколько дней хватит баланса при текущем заказе" />
</th>
<th class="text-right sc-num">Проектов</th>
<th class="text-right sc-num">
Пришло
<HelpHint text="Сколько лидов доставлено клиенту за выбранный период" />
</th>
<th class="text-right sc-num">
Оборот
<HelpHint text="Сумма, на которую клиент получил лидов за период" />
</th>
<th>
Тариф
<HelpHint
text="Тариф этого клиента. Закрепляется при привязке и не меняется, даже если ваш тариф потом изменят"
/>
</th>
<th class="text-right sc-num">
Заработал
<HelpHint text="Ваша комиссия с этого клиента за период" />
</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.tenant_id"
class="sc-row"
data-testid="client-row"
@click="openClient(row)"
>
<!-- Клиент -->
<td class="sc-name-cell">
<span class="sc-org">{{ row.organization_name }}</span>
<span v-if="row.inn" class="sc-inn">ИНН {{ row.inn }}</span>
</td>
<!-- Тип -->
<td>
<span v-if="row.subject_type" class="sc-type-badge">
{{ subjectLabel(row.subject_type) }}
</span>
<span v-else class="text-medium-emphasis"></span>
</td>
<!-- Активность -->
<td class="text-medium-emphasis sc-activity">
{{ fmtActivity(row.last_activity_at) }}
</td>
<!-- Баланс -->
<td class="text-right sc-num sc-mono">
{{ fmtMoney(row.balance_rub) }}
</td>
<!-- Запас -->
<td class="text-right sc-num sc-mono">
{{ fmtRunway(row.runway_days) }}
</td>
<!-- Проектов -->
<td class="text-right sc-num sc-mono">
{{ row.projects_count }}
</td>
<!-- Пришло -->
<td class="text-right sc-num sc-mono">
{{ row.leads_delivered }}
</td>
<!-- Оборот -->
<td class="text-right sc-num sc-mono">
{{ fmtMoney(row.oborot_rub) }}
</td>
<!-- Тариф -->
<td>
<span v-if="row.tariff_name" class="sc-tariff">{{ row.tariff_name }}</span>
<span v-else class="text-medium-emphasis"></span>
</td>
<!-- Заработал Phase 3, always «» for now -->
<td class="text-right sc-num text-medium-emphasis" data-testid="earned-cell"></td>
<!-- Статус -->
<td>
<v-chip
:color="statusMeta(row.status).color"
:variant="statusMeta(row.status).variant"
size="x-small"
data-testid="status-chip"
>
{{ statusMeta(row.status).label }}
</v-chip>
</td>
</tr>
<!-- Empty state -->
<tr v-if="rows.length === 0 && !loading">
<td colspan="11" class="text-center text-medium-emphasis pa-6">Клиенты не найдены</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.sales-clients {
max-width: 1500px;
}
.sc-page-title {
color: #081319;
letter-spacing: -0.02em;
}
.sc-row {
cursor: pointer;
}
.sc-row:hover td {
background: rgba(15, 110, 86, 0.05);
}
.sc-name-cell {
min-width: 180px;
}
.sc-org {
display: block;
font-weight: 500;
color: #081319;
font-size: 13px;
}
.sc-inn {
display: block;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 11px;
color: #66635c;
margin-top: 1px;
}
.sc-type-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
padding: 2px 7px;
border-radius: 4px;
background: #e1eeea;
color: #084635;
white-space: nowrap;
}
.sc-activity {
font-size: 12.5px;
white-space: nowrap;
}
.sc-num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.sc-mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.sc-tariff {
font-size: 12px;
color: #343c41;
}
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
/**
* Экран входа в портал отдела продаж.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_sales.html #screen-login.
* Двухколоночный split: левая — брендовая плашка (#012019), правая — форма.
*
* Auth: email + password → POST /api/sales/auth/login → токен в salesAuth store.
* После успешного входа — redirect /sales.
*/
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSalesAuthStore } from '../../stores/salesAuth';
import { extractSalesErrorMessage } from '../../api/sales';
const router = useRouter();
const salesAuth = useSalesAuthStore();
const email = ref('');
const password = ref('');
const errorMessage = ref<string | null>(null);
const loading = ref(false);
const showPassword = ref(false);
async function handleSubmit() {
errorMessage.value = null;
loading.value = true;
try {
await salesAuth.login(email.value, password.value);
await router.push('/sales');
} catch (err: unknown) {
errorMessage.value = extractSalesErrorMessage(err, 'Неверный email или пароль. Попробуйте ещё раз.');
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="sso-shell">
<!-- Левая колонка: бренд -->
<aside class="sso-brand">
<div class="sso-brand-head">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
<path
d="M16 14 L16 34 L32 34"
stroke="#012019"
stroke-width="4.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
</svg>
</span>
<span class="brand-name">Лидерра<span class="brand-dot">.</span></span>
<span class="brand-tag">ОТДЕЛ ПРОДАЖ</span>
</div>
<div class="sso-brand-body">
Портал <em>менеджеров по продажам</em>.<br />
Свои клиенты, их деньги и активность в одном месте.
</div>
<div class="sso-brand-foot">v8 Forest · Лидерра CRM</div>
</aside>
<!-- Правая колонка: форма -->
<main class="sso-form">
<div class="sso-card">
<h1 class="sso-title">Вход</h1>
<p class="sso-subtitle">Введите данные вашей учётной записи.</p>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="login-error"
rounded="lg"
>
{{ errorMessage }}
</v-alert>
<form @submit.prevent="handleSubmit">
<v-text-field
v-model="email"
label="Email"
type="email"
autocomplete="email"
variant="outlined"
density="comfortable"
class="mb-3"
:disabled="loading"
data-testid="email-field"
required
/>
<v-text-field
v-model="password"
label="Пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
variant="outlined"
density="comfortable"
class="mb-4"
:disabled="loading"
data-testid="password-field"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
required
@click:append-inner="showPassword = !showPassword"
/>
<v-btn
type="submit"
color="#0F6E56"
variant="flat"
size="large"
block
:loading="loading"
data-testid="submit-btn"
>
Войти
</v-btn>
</form>
</div>
</main>
</div>
</template>
<style scoped>
.sso-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 900px) {
.sso-shell {
grid-template-columns: 1fr;
}
.sso-brand {
display: none;
}
}
/* Левая: брендовая плашка */
.sso-brand {
background: #012019;
color: #ffffff;
padding: 56px 60px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.sso-brand-head {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 16px;
}
.brand-mark {
width: 24px;
height: 24px;
border-radius: 5px;
background: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.brand-name {
font-weight: 600;
font-size: 16px;
color: #ffffff;
letter-spacing: -0.01em;
}
.brand-dot {
color: #32c8a9;
}
.brand-tag {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 3px;
background: #0f6e56;
color: #ffffff;
margin-left: auto;
font-weight: 600;
}
.sso-brand-body {
font-size: 30px;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.2;
max-width: 440px;
color: #ffffff;
}
.sso-brand-body em {
color: #32c8a9;
font-style: normal;
}
.sso-brand-foot {
font-size: 12px;
color: #7a8c87;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
/* Правая: форма */
.sso-form {
background: #f6f3ec;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 32px;
}
.sso-card {
width: 100%;
max-width: 360px;
}
.sso-title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.018em;
margin: 0 0 6px;
line-height: 1.2;
color: #081319;
}
.sso-subtitle {
font-size: 12.5px;
color: #66635c;
line-height: 1.5;
margin: 0 0 20px;
}
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
/**
* Заглушка для экранов портала продаж, которые будут реализованы в следующих фазах.
* Принимает необязательный prop `title` для отображения названия страницы.
*/
defineProps<{
title?: string;
}>();
</script>
<template>
<div class="pa-8">
<div class="text-h5 font-weight-semibold mb-2" style="color: #081319">
{{ title ?? 'Скоро' }}
</div>
<p style="color: #66635c; font-size: 14px">Этот раздел будет реализован в следующих фазах разработки.</p>
</div>
</template>
@@ -37,9 +37,9 @@ const lookupMessage = ref('');
const lookupError = ref(false);
const subjectTypes = [
{ value: 'individual', label: 'Физлицо' },
{ value: 'individual', label: 'Физическое лицо' },
{ value: 'sole_proprietor', label: 'ИП' },
{ value: 'legal_entity', label: 'Юрлицо' },
{ value: 'legal_entity', label: 'Юридическое лицо' },
];
const requiresInn = computed(
@@ -49,8 +49,10 @@ const requiresInn = computed(
const isLegalEntity = computed(() => form.subject_type === 'legal_entity');
const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor');
// Блок платёжных реквизитов виден, как только выбран тип лица.
const showPayment = computed(() => form.subject_type !== null);
// Блок платёжных реквизитов виден для ИП и юрлица; у физлица банковских реквизитов нет.
const showPayment = computed(
() => form.subject_type !== null && form.subject_type !== 'individual',
);
// КПП только юрлицо; ОГРН/ОГРНИП и юр.адрес юрлицо и ИП; банк всегда (когда showPayment).
const showKpp = computed(() => isLegalEntity.value);
const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value);
@@ -0,0 +1,7 @@
<p>Проверка внешних сервисов в {{ $checkedAt->format('d.m.Y H:i') }} (МСК) выявила проблемы:</p>
<ul>
@foreach ($services as $s)
<li><strong>{{ $s['key'] }}</strong> {{ $s['detail'] }}</li>
@endforeach
</ul>
<p>Проверьте баланс / доступность сервиса в админке Лидерры (плитка «Внешние сервисы»).</p>
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px; text-align: left; }
th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.sign { margin-top: 30px; }
.sign td { border: none; padding: 8px 4px; }
</style>
</head>
<body>
<h1>Акт {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}</h1>
<p><b>Исполнитель:</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}</p>
<p><b>Заказчик:</b> {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}</p>
<p><b>Основание:</b> счёт {{ $invoiceNumber }}</p>
<table>
<tr><th></th><th>Наименование услуги</th><th>Сумма</th></tr>
<tr><td>1</td><td>Оплата генерации рекламных лидов</td><td>{{ number_format((float) $act->amount_total, 2, '.', ' ') }} </td></tr>
</table>
<p class="right"><b>Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p>Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.</p>
<table class="sign">
<tr>
<td style="width:50%">Исполнитель<br><br>_______________ / {{ $seller->director_name ?? $seller->name }}</td>
<td style="width:50%">Заказчик<br><br>_______________ / {{ $act->buyer_name }}</td>
</tr>
</table>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
.bank td { border: 1px solid #000; padding: 4px; vertical-align: top; }
.items th, .items td { border: 1px solid #000; padding: 4px; text-align: left; }
.items th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.muted { color: #555; }
</style>
</head>
<body>
<table class="bank">
<tr>
<td rowspan="2" style="width:55%">{{ $seller->bank_name }}</td>
<td style="width:15%">БИК</td>
<td>{{ $seller->bank_bik }}</td>
</tr>
<tr>
<td>Сч. </td>
<td>{{ $seller->bank_corr }}</td>
</tr>
<tr>
<td>Получатель<br>{{ $seller->name }}<br>ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}</td>
<td>Сч. </td>
<td>{{ $seller->bank_account }}</td>
</tr>
</table>
<h1>Счёт на оплату {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}</h1>
<p><b>Поставщик (Исполнитель):</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}</p>
<p><b>Покупатель (Заказчик):</b> {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}</p>
<table class="items">
<tr><th></th><th>Наименование</th><th>Кол-во</th><th>Ед.</th><th>Цена</th><th>Сумма</th></tr>
@foreach($items as $i => $it)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $it->name }}</td>
<td>{{ (int) $it->quantity }}</td>
<td>{{ $it->unit }}</td>
<td>{{ number_format((float) $it->price, 2, '.', ' ') }}</td>
<td>{{ number_format((float) $it->amount_total, 2, '.', ' ') }}</td>
</tr>
@endforeach
</table>
<p class="right"><b>Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p><b>Назначение платежа:</b> {{ $invoice->payment_purpose }}</p>
<p class="muted">Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}</p>
</body>
</html>
+8
View File
@@ -104,6 +104,14 @@ Schedule::command('billing:preflight-sweep')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
Schedule::command('invoices:expire')
->dailyAt('03:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
+29
View File
@@ -1,5 +1,7 @@
<?php
use App\Http\Controllers\Api\Sales\SalesAuthController;
use App\Http\Controllers\Api\Sales\SalesClientsController;
use Illuminate\Support\Facades\Route;
// Laravel 13 string-based lazy-loading контроллеров (Sprint 2 Phase A, O-stack-03).
@@ -139,6 +141,11 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1).
Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index');
Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')
->whereNumber('id');
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
@@ -222,6 +229,24 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
});
});
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
// Всё через admin-db (crm_admin_user): и логин, и проверка токена, и cross-tenant
// чтение; каждый запрос данных фильтруется по владению (ScopesSalesOwnership).
// admin-db СТОИТ ПЕРЕД auth:sales (Sanctum читает токены/sales_users под crm_admin_user).
Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () {
Route::post('/login', [SalesAuthController::class, 'login']);
Route::middleware('auth:sales')->group(function () {
Route::get('/me', [SalesAuthController::class, 'me']);
Route::post('/logout', [SalesAuthController::class, 'logout']);
});
});
// Зона данных портала (наполняется в Фазах 1–7).
Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () {
Route::get('/clients', [SalesClientsController::class, 'index']);
Route::get('/clients/{tenantId}', [SalesClientsController::class, 'show'])->whereNumber('tenantId');
// attachments, income, tariffs, payouts, invoices, managers, dashboard
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
@@ -238,6 +263,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store');
Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id');
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
@@ -381,6 +409,7 @@ Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт истори
Route::view('/admin', 'welcome');
Route::view('/admin/tenants', 'welcome');
Route::view('/admin/billing', 'welcome');
Route::view('/admin/invoices', 'welcome');
Route::view('/admin/incidents', 'welcome');
Route::view('/admin/system', 'welcome');
Route::view('/admin/pricing-tiers', 'welcome');
@@ -170,6 +170,51 @@ test('GET /api/admin/tenants mrr_rub=null если current_tariff_id отсут
expect($r->json('tenants.0.mrr_rub'))->toBeNull();
});
test('GET /api/admin/tenants фильтрует по statuses (производный статус, multi)', function () {
// active: не trial, status=active, balance>=0, chargeback=0
Tenant::factory()->create(['organization_name' => 'AC', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']);
// trial: is_trial=true (приоритет выше всех)
Tenant::factory()->create(['organization_name' => 'TR', 'status' => 'active', 'is_trial' => true, 'balance_rub' => '0']);
// overdue: не trial, balance<0
Tenant::factory()->create(['organization_name' => 'OV', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '-50', 'chargeback_unrecovered_rub' => '0']);
// suspended: status=suspended
Tenant::factory()->create(['organization_name' => 'SU', 'status' => 'suspended', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']);
$r = $this->getJson('/api/admin/tenants?statuses=overdue,trial');
$names = collect($r->json('tenants'))->pluck('organization_name')->all();
expect($r->json('total'))->toBe(2);
expect($names)->toContain('TR');
expect($names)->toContain('OV');
expect($names)->not->toContain('AC');
expect($names)->not->toContain('SU');
});
test('GET /api/admin/tenants фильтрует по tariffs (имя тарифа, multi)', function () {
$mk = fn (string $name): int => (int) DB::table('tariff_plans')->insertGetId([
'code' => 'tp_'.bin2hex(random_bytes(4)),
'name' => $name,
'billing_model' => 'monthly',
'price_monthly' => 990.00,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
'created_at' => now(),
]);
$proId = $mk('Pro');
$teamId = $mk('Команда');
$p = Tenant::factory()->create(['organization_name' => 'P1']);
DB::table('tenants')->where('id', $p->id)->update(['current_tariff_id' => $proId]);
$k = Tenant::factory()->create(['organization_name' => 'K1']);
DB::table('tenants')->where('id', $k->id)->update(['current_tariff_id' => $teamId]);
$r = $this->getJson('/api/admin/tenants?tariffs=Pro');
expect($r->json('total'))->toBe(1);
expect($r->json('tenants.0.organization_name'))->toBe('P1');
});
test('GET /api/admin/tenants поддерживает limit + offset', function () {
foreach (range(1, 5) as $i) {
Tenant::factory()->create([
@@ -4,9 +4,11 @@ declare(strict_types=1);
use App\Models\ApiKey;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
@@ -48,6 +50,22 @@ test('валидный ключ → 200 и только свои сделки',
expect($r->json('data'))->toHaveCount(2);
});
test('project в ответе без канального префикса B<N>_ (не палим поставщика)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'name' => 'B6_okna.ru']);
Deal::factory()->create([
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'received_at' => now(),
]);
$key = makeApiKey($tenant->id, $user->id);
$r = $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]);
$r->assertOk();
expect($r->json('data.0.project'))->toBe('okna.ru');
});
test('нет заголовка → 401', function () {
$this->getJson('/api/v1/deals')->assertStatus(401);
});

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