Files
portal/app/routes/web.php
T
Дмитрий b6a15c5bbf merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.

Девять столкновений, каждое разобрано по существу.

Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.

Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.

Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.

Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.

Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.

СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.

Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
  - 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
    перенесены из рабочей ветки, где владелец их уже принял;
  - остальные 42 - свои, в новом коде ветки, и починены по существу:
    задвоенный ключ массива в трёх тестах (след копирования - комментарий
    оторвался от своей строки), врущие описания двух помощников (PHP сам
    делает из ключа-номера число), сужение типа возврата, прятавшее от
    анализатора свойства подставного отправителя, лишний знак вопроса и
    четыре бесполезных перенумерования списка.
  - Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
    поимённо и покраснел; убрал - ноль.

Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.

Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.

Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.

Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.

NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:50:41 +03:00

771 lines
63 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Http\Controllers\Api\CreativeRobotController;
use App\Http\Controllers\Api\Sales\SalesAdAudienceController;
use App\Http\Controllers\Api\Sales\SalesAttachmentController;
use App\Http\Controllers\Api\Sales\SalesAuthController;
use App\Http\Controllers\Api\Sales\SalesClientsController;
use App\Http\Controllers\Api\Sales\SalesDashboardController;
use App\Http\Controllers\Api\Sales\SalesIncomeController;
use App\Http\Controllers\Api\Sales\SalesIntegrationController;
use App\Http\Controllers\Api\Sales\SalesInvoiceController;
use App\Http\Controllers\Api\Sales\SalesManagersController;
use App\Http\Controllers\Api\Sales\SalesPayoutController;
use App\Http\Controllers\Api\Sales\SalesProspectController;
use App\Http\Controllers\Api\Sales\SalesSmsController;
use App\Http\Controllers\Api\Sales\SalesTariffController;
use App\Http\Controllers\Api\TgRobotController;
use Illuminate\Support\Facades\Route;
// Laravel 13 string-based lazy-loading контроллеров (Sprint 2 Phase A, O-stack-03).
//
// Контроллеры передаются как строки 'FQN@method' вместо
// [Class::class, 'method']. При таком синтаксисе Laravel НЕ автозагружает
// класс при boot'е route'ов — резолвит из строки только при матче маршрута.
// Экономит ~50-100 ms на cold-boot для middleware-пайплайна с 20+ контроллерами.
//
// Почему именно строки, а не FQN-class:
// - Pint default preset (Laravel) применяет fixer fully_qualified_strict_types,
// который сворачивает \App\Http\...\X::class обратно в use-импорт.
// На строки этот fixer не действует — синтаксис стабилен под форматтером.
//
// SPA-API маршруты auth-flow. Размещены в web.php (а не api.php), потому
// что Sanctum SPA mode использует session-cookie auth, а session middleware
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
Route::prefix('/api/auth')->group(function () {
// Route-throttle (P1 go-live): per-IP лимит поверх per-credential rate-limit
// в контроллерах. Именованные лимитеры — в AppServiceProvider::boot.
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login')
->middleware('throttle:auth-login');
Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register')
->middleware('throttle:auth-register');
Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail')
->middleware('throttle:auth-register');
Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode')
->middleware('throttle:auth-register');
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
// pending_user_id в session. Verify завершает login после проверки TOTP.
//
// Sprint 3 Phase B (audit O-refactor-02): 2FA verify/recovery-use вынесены
// из AuthController в TwoFactorController; forgot/reset — в PasswordResetController.
// URL без изменений.
Route::post('/2fa/verify', 'App\Http\Controllers\Api\TwoFactorController@verifyTwoFactor')
->middleware('throttle:auth-2fa');
// /2fa/recovery-use — публичный (нет полноценной session-auth до verify).
Route::post('/2fa/recovery-use', 'App\Http\Controllers\Api\TwoFactorController@useRecoveryCode')
->middleware('throttle:auth-2fa');
// /forgot — публичный (anti-enumeration unified-ответ + rate-limit).
Route::post('/forgot', 'App\Http\Controllers\Api\PasswordResetController@forgotPassword')
->middleware('throttle:auth-password');
// /reset-password — публичный (deep-link из email с token+email+password).
Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword')
->middleware('throttle:auth-password');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
});
});
// Аккаунт — вкладка «Безопасность» (UI-аудит 21.06.2026): смена пароля + недавние входы.
Route::middleware('auth:sanctum')->prefix('/api/account')->group(function () {
Route::post('/change-password', 'App\Http\Controllers\Api\AccountController@changePassword')
->middleware('throttle:auth-password');
Route::get('/security', 'App\Http\Controllers\Api\AccountController@security');
Route::delete('/sessions/{id}', 'App\Http\Controllers\Api\AccountController@revokeSession')
->whereNumber('id');
});
// In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth —
// уведомления USER-personal, читать/писать может только сам user.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/notifications')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\InAppNotificationController@index');
Route::patch('/{id}/read', 'App\Http\Controllers\Api\InAppNotificationController@markRead')->where('id', '[0-9]+');
Route::post('/mark-all-read', 'App\Http\Controllers\Api\InAppNotificationController@markAllRead');
Route::delete('/{id}', 'App\Http\Controllers\Api\InAppNotificationController@destroy')->where('id', '[0-9]+');
});
// Реквизиты тенанта (G1/SP2). Лёгкий гейт первого проекта + дозаполнение в ЛК.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/tenant/requisites')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\TenantRequisitesController@show');
Route::put('/', 'App\Http\Controllers\Api\TenantRequisitesController@update');
Route::post('/lookup-inn', 'App\Http\Controllers\Api\TenantRequisitesController@lookupInn')
->middleware('throttle:30,1');
});
// Клиентская Telegram-реклама «по своей базе» (client_tg_*). RLS требует tenant
// middleware. Создание (store) и запуск (launch) разделены: store — черновик,
// launch — очередь + робот. См. Api\ClientTg\CampaignController.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(function () {
Route::get('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@index');
Route::post('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@store');
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\ClientTg\CampaignController@show')->whereNumber('id');
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\ClientTg\CampaignController@launch')->whereNumber('id');
Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\ClientTg\CampaignController@cancel')->whereNumber('id');
Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id');
Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id');
// Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
});
// Reports backend. Schema §13.5 report_jobs. Auth обязательный.
// Этапы 1+2 (CRUD + provider/formatter) + этап 3 (retry/cancel/delete +
// retention cron `reports:cleanup-expired`).
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/reports/jobs')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ReportJobController@index');
Route::post('/', 'App\Http\Controllers\Api\ReportJobController@store');
Route::get('/{id}', 'App\Http\Controllers\Api\ReportJobController@show')->where('id', '[0-9]+');
Route::post('/{id}/retry', 'App\Http\Controllers\Api\ReportJobController@retry')->where('id', '[0-9]+');
Route::post('/{id}/cancel', 'App\Http\Controllers\Api\ReportJobController@cancel')->where('id', '[0-9]+');
Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+');
});
// F2 (audit): скачивание готового файла отчёта по signed URL (24 ч, OPEN-И-20).
// НЕ под auth:sanctum — подпись URL = capability-token (генерируется только
// в ReportJobController::toResource() для отчётов своего тенанта).
Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')
->where('id', '[0-9]+')
->name('reports.download')
->middleware('signed');
// SaaS-admin зона (/api/admin/*). Гейт `saas-admin` = EnsureSaasAdmin: на проде
// fail-closed по nginx HTTP Basic Auth (^~ /api/admin, .htpasswd-admin) + M-1
// app-слой (REMOTE_USER ∈ ADMIN_ALLOWED_USERS, закрывает обходы фронт-контроллера)
// + запрет входа во время impersonation. Реальный Yandex 360 SSO — TODO под Б-1+DO-4.
// admin_user_id для audit — трейт ResolvesAdminUserId (отдельная зона).
// admin-db (UseAdminConnection) — ПОСЛЕ saas-admin: на время admin-запроса
// default-подключение = pgsql_admin (роль crm_admin_user, srv_bypass), чтобы
// AdminTenants/AdminBillingController видели все тенанты после переезда на
// Managed PG (Путь А). Контроллеры на pgsql_supplier не затрагиваются.
Route::middleware(['saas-admin', 'admin-db'])->group(function () {
// Командный центр (дашборд) — read-only агрегаты L1 + L2.
Route::get('/api/admin/dashboard', 'App\Http\Controllers\Api\AdminDashboardController@summary');
Route::get('/api/admin/dashboard/finance', 'App\Http\Controllers\Api\AdminDashboardController@finance');
Route::get('/api/admin/dashboard/health', 'App\Http\Controllers\Api\AdminDashboardController@health');
Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads');
Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances');
Route::post('/api/admin/dashboard/balances/refresh', 'App\Http\Controllers\Api\AdminDashboardController@refreshBalances');
Route::get('/api/admin/dashboard/clients', 'App\Http\Controllers\Api\AdminDashboardController@clients');
Route::get('/api/admin/leads', 'App\Http\Controllers\Api\AdminLeadsController@index');
Route::get('/api/admin/leads/{id}', 'App\Http\Controllers\Api\AdminLeadsController@show')->whereNumber('id');
// SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin).
Route::prefix('/api/admin/impersonation')->group(function () {
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
});
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
->where('subdomain', '[a-z0-9_-]+');
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
->where('id', '[0-9]+');
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// T4a: SaaS-admin → Реклама: расход у Яндекса и наша маржа по тенантам (ad_wallet_transactions).
Route::get('/api/admin/advertising/spend', 'App\Http\Controllers\Api\AdminAdvertisingController@spend');
Route::get('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@settings');
Route::put('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@updateSettings');
// 8e: кампании, ждущие номер креатива Яндекса, перед запуском.
Route::get('/api/admin/advertising/campaigns', 'App\Http\Controllers\Api\AdminAdvertisingController@campaignsAwaiting');
Route::patch('/api/admin/advertising/campaigns/{id}/creative', 'App\Http\Controllers\Api\AdminAdvertisingController@setCampaignCreative')
->whereNumber('id');
// Задача 15: «ждёт разбора» — кампании, где робот сходил в кабинет и не понял, что видит.
Route::get('/api/admin/advertising/robot-stuck', 'App\Http\Controllers\Api\AdminAdvertisingController@robotStuck');
// Task 8: SaaS-admin → Клиентские СМС: тарифная сетка + общие настройки (client_sms_*).
Route::get('/api/admin/sms/tariffs', 'App\Http\Controllers\Api\Admin\AdminSmsTariffController@index');
Route::put('/api/admin/sms/tariffs', 'App\Http\Controllers\Api\Admin\AdminSmsTariffController@updateTariffs');
Route::put('/api/admin/sms/settings', 'App\Http\Controllers\Api\Admin\AdminSmsTariffController@updateSettings');
// Этап 5 Task 8: SaaS-admin → сверка нашего расчёта расхода перед оператором
// с тем, что насчитал сам оператор (строка 5.7). Только владелец (В-201).
Route::get('/api/admin/sms/reconcile', 'App\Http\Controllers\Api\Admin\AdminSmsReconcileController@index');
// Этап 1: SaaS-admin → общий стоп-лист портала. Номер, внесённый сюда, перестаёт
// получать СМС у ВСЕХ клиентов сразу (защита договора с МТС при жалобе).
Route::get('/api/admin/sms/global-optouts', 'App\Http\Controllers\Api\Admin\AdminSmsOptoutController@index');
Route::post('/api/admin/sms/global-optouts', 'App\Http\Controllers\Api\Admin\AdminSmsOptoutController@store');
Route::delete('/api/admin/sms/global-optouts/{phone}', 'App\Http\Controllers\Api\Admin\AdminSmsOptoutController@destroy')
->where('phone', '[0-9]{11}');
// Task 13b: SaaS-admin → согласование «своего имени отправителя» (client_sms_senders).
Route::get('/api/admin/sms/senders', 'App\Http\Controllers\Api\Admin\AdminSmsSenderController@index');
Route::get('/api/admin/sms/senders/{id}/document/{kind}', 'App\Http\Controllers\Api\Admin\AdminSmsSenderController@document')->whereNumber('id')->where('kind', '[a-z]+');
Route::post('/api/admin/sms/senders/{id}/approve', 'App\Http\Controllers\Api\Admin\AdminSmsSenderController@approve')->whereNumber('id');
Route::post('/api/admin/sms/senders/{id}/reject', 'App\Http\Controllers\Api\Admin\AdminSmsSenderController@reject')->whereNumber('id');
Route::post('/api/admin/sms/senders/{id}/disable', 'App\Http\Controllers\Api\Admin\AdminSmsSenderController@disable')->whereNumber('id');
// 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')
->where('id', '[0-9]+');
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
->where('id', '[0-9]+');
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
->where('id', '[0-9]+');
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
->where('id', '[0-9]+');
// Sprint 3D (G6): РКН-notify endpoint (152-ФЗ).
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
->where('id', '[0-9]+');
// Раздел «Посетители» (spec 2026-07-13) — свой счётчик, не Метрика.
Route::get('/api/admin/visitors/funnel', 'App\Http\Controllers\Api\AdminVisitorsController@funnel');
Route::get('/api/admin/visitors/channels', 'App\Http\Controllers\Api\AdminVisitorsController@channels');
Route::get('/api/admin/visitors/visits', 'App\Http\Controllers\Api\AdminVisitorsController@visits');
Route::get('/api/admin/visitors/portal', 'App\Http\Controllers\Api\AdminVisitorsController@portalActivity');
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
// Авторизация — через гейт группы saas-admin (EnsureSaasAdmin: nginx-basic + M-1).
Route::prefix('/api/admin/system-settings')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
});
// SaaS-admin → Система: ручной баланс Яндекс 360 (почта).
Route::get('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@show');
Route::put('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@update');
// SaaS-admin → Биллинг: ввод секретных ключей платёжного шлюза (config зашифрован).
Route::put('/api/admin/payment-gateways/{code}', 'App\Http\Controllers\Api\AdminPaymentGatewayController@update')
->where('code', '[a-z0-9_]+');
// Plan 4: SaaS-admin pricing-tiers editor.
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
Route::prefix('/api/admin/pricing-tiers')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
Route::delete('/scheduled/{effective_from}',
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
->where('effective_from', '\d{4}-\d{2}-\d{2}');
});
// Plan 4 Task 10: SaaS-admin supplier prices editor.
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
->where('id', '[0-9]+');
// Резервный CSV-канал (Путь 2): здоровье канала + ручной запуск сверки.
Route::get('/api/admin/supplier-integration', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@index');
Route::post('/api/admin/supplier-integration/reconcile', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@reconcile');
// Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs).
Route::get('/api/admin/supplier-integration/sync-runs', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@syncRuns');
// Резерв канала миграции проектов (ярус 3): ручная очередь оператора.
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
->where('id', '[0-9]+');
// Plan 4 Task 1: глобальный тумблер режима экспорта проектов (online|batch).
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
Route::get('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getSourceEditFlag');
Route::post('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setSourceEditFlag');
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
// 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4).
Route::prefix('/api/admin/pd-subject-requests')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store');
Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show')
->where('id', '[0-9]+');
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
->where('id', '[0-9]+');
});
// Telegram-модуль, задача 4.4: тарифная сетка клиентской Telegram-рекламы
// (client_tg_tariffs, глобальная).
Route::get('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@index');
Route::put('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@updateTariffs');
});
// Портал отдела продаж (/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');
Route::get('/overview', [SalesClientsController::class, 'overview']); // Task 1.5
// Task 2.3: заявки на привязку клиентов
Route::post('/attachments', [SalesAttachmentController::class, 'store']);
Route::get('/attachments', [SalesAttachmentController::class, 'index']);
Route::post('/attachments/{id}/decide', [SalesAttachmentController::class, 'decide'])->whereNumber('id');
// Task 3.2a: каталог тарифов + назначение текущего тарифа менеджеру (только head)
Route::get('/tariffs', [SalesTariffController::class, 'index']);
Route::post('/tariffs', [SalesTariffController::class, 'store']);
Route::match(['put', 'patch'], '/tariffs/{id}', [SalesTariffController::class, 'update'])->whereNumber('id');
Route::post('/tariffs/assign', [SalesTariffController::class, 'assign']);
// Этап 1 (воронка «Потенциальные клиенты»): доски менеджера/начальника.
Route::get('/prospects', [SalesProspectController::class, 'index']);
Route::post('/prospects', [SalesProspectController::class, 'store']);
// §16.4: подсказка юрлица/города по ИНН (ДаData). Ничего не сохраняет.
Route::post('/prospects/lookup-inn', [SalesProspectController::class, 'lookupInn']);
Route::patch('/prospects/{id}', [SalesProspectController::class, 'update'])->whereNumber('id');
// §18: менеджер фиксирует контактных лиц после обзвона предполагаемых номеров.
Route::patch('/prospects/{id}/contacts', [SalesProspectController::class, 'updateContacts'])->whereNumber('id');
// §19: журнал разговоров — история не теряется между звонками.
Route::get('/prospects/{id}/notes', [SalesProspectController::class, 'notes'])->whereNumber('id');
Route::post('/prospects/{id}/notes', [SalesProspectController::class, 'storeNote'])->whereNumber('id');
// Task 4.1: выплаты менеджерам (append-only журнал). remaining/store — только head.
Route::get('/payouts/remaining', [SalesPayoutController::class, 'remaining']);
Route::get('/payouts', [SalesPayoutController::class, 'index']);
Route::post('/payouts', [SalesPayoutController::class, 'store']);
// Task 4.2a: сводка «Мой доход» авторизованного менеджера.
Route::get('/income', [SalesIncomeController::class, 'show']);
// Task 5.1a: счета клиентов + отметка оплаты (только head; зачисление — InvoicePaymentService).
Route::get('/invoices', [SalesInvoiceController::class, 'index']);
Route::post('/invoices/{id}/mark-paid', [SalesInvoiceController::class, 'markPaid'])->whereNumber('id');
// Task 6.1a: сводка отдела «Дашборд начальника» (только head).
Route::get('/dashboard/overview', [SalesDashboardController::class, 'overview']);
// Task 6.2a: результативность менеджеров (только head).
Route::get('/managers/performance', [SalesManagersController::class, 'performance']);
// Task 7.1a: заведение менеджеров начальником + список аккаунтов (только head).
Route::get('/managers', [SalesManagersController::class, 'index']);
Route::post('/managers', [SalesManagersController::class, 'store']);
// Task 4+5 (план трёх площадок): «Реклама на кандидатов» по площадкам yandex|vk|mts
// (только head). Неизвестную площадку контроллер отвергает сам (platformOr404 → 404
// JSON) — без whereIn на маршруте, иначе непойманный сегмент проваливается
// в Route::fallback (SPA welcome-view) вместо чистого 404-ответа API.
Route::get('/warming/{platform}', [SalesAdAudienceController::class, 'show']);
Route::patch('/warming/{platform}', [SalesAdAudienceController::class, 'update']);
Route::get('/warming/{platform}/firms', [SalesAdAudienceController::class, 'firms']);
Route::post('/warming/{platform}/firms/{firm}/toggle', [SalesAdAudienceController::class, 'toggle'])->whereNumber('firm');
Route::post('/warming/{platform}/firms/{firm}/assign', [SalesAdAudienceController::class, 'assign'])->whereNumber('firm');
// Task C2 (Фаза 2 Этап C): массовый «Греть» — выбор режима funnel/flat, тело — firm_ids.
// Literal-сегмент 'warm' не конфликтует с 'firms' (toggle/assign) и с /warming/mts/file ниже.
Route::post('/warming/{platform}/warm', [SalesAdAudienceController::class, 'warm']);
// Task C3 (Фаза 2 Этап C): массовый «Убрать» — снять фирмы с площадки, тело — firm_ids.
Route::post('/warming/{platform}/unassign', [SalesAdAudienceController::class, 'unassign']);
// Task 3 (план трёх площадок): выгрузка списка номеров файлом для МТС (только head).
// Регистрируем ДО /warming/{platform}/firms — иначе .../firms не перепутается,
// но mts/file имеет другое число сегментов, поэтому порядок не критичен.
Route::get('/warming/mts/file', [SalesAdAudienceController::class, 'mtsFile']);
// Регрессия: старая /ad-audience/firms была снесена разъездом на площадки, но модуль
// «Прогрев СМС» (SalesSmsView) зовёт именно её для фирмо-пикера — восстановлена как
// платформо-независимый список ВСЕХ фирм прогрева (allFirms(), НЕ путать с firms()).
Route::get('/ad-audience/firms', [SalesAdAudienceController::class, 'allFirms']);
// Task D1 (Фаза 2 Этап D): только фирмы 4-го канала «СМС» (firm_channels(channel='sms')),
// с полем sms_sent_count. Literal-сегмент 'sms-firms' не конфликтует с 'firms' выше.
Route::get('/ad-audience/sms-firms', [SalesAdAudienceController::class, 'smsFirms']);
// Task 2 (кусок «Прогрев СМС»): назначение менеджера платформо-независимым маршрутом —
// у СМС нет понятия «площадка», карточка воронки в assignAny() та же, что у /warming/{platform}/....
Route::post('/ad-audience/firms/{firm}/assign', [SalesAdAudienceController::class, 'assignAny'])->whereNumber('firm');
// Task 9 (план «Прогрев СМС»): список рассылок, предпросмотр цены, запуск (только head).
Route::get('/sms/campaigns', [SalesSmsController::class, 'index']);
Route::post('/sms/campaigns', [SalesSmsController::class, 'store']);
Route::get('/sms/campaigns/{id}', [SalesSmsController::class, 'show'])->whereNumber('id');
Route::post('/sms/preview', [SalesSmsController::class, 'preview']);
// Task 10.5 (план «Прогрев СМС»): номера отмеченных галочками фирм (только head).
Route::post('/sms/recipients', [SalesSmsController::class, 'recipients']);
// managers, dashboard
});
// Сервис-канал «Поиск → Портал» (Этап 2). Токен вместо пользователя (sales-integration),
// admin-db — чтобы писать в sales_prospects под crm_admin_user. Дизайн §7.
Route::middleware(['admin-db', 'sales-integration'])->prefix('api/sales/integration')->group(function () {
Route::get('/managers', [SalesIntegrationController::class, 'managers']);
Route::post('/prospects', [SalesIntegrationController::class, 'ingest']);
// Рекламная аудитория: мобильные номера директоров отмеченных фирм.
Route::post('/ad-audience', [SalesIntegrationController::class, 'ingestAdAudience']);
// Витрина прогрева (кусок B, B6): состояние прогрева по списку ИНН для finder.
Route::post('/warming-state', [SalesIntegrationController::class, 'warmingState']);
});
// Служебный канал «Робот-грузчик креативов → Портал». Токен вместо пользователя.
// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user.
// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного
// входа. Тот же порядок, что у админского канала (см. комментарий в UseAdminConnection).
Route::middleware(['creative-robot', 'admin-db'])->prefix('api/creative-robot')->group(function () {
Route::get('/next', [CreativeRobotController::class, 'next']);
// Номер задания — часть адреса файла: робот получает файлы только того задания,
// которое ему выдали, а не «какого-нибудь, что сейчас в работе».
Route::get('/jobs/{jobId}/banners/{bannerId}/file', [CreativeRobotController::class, 'file'])
->whereNumber(['jobId', 'bannerId']);
Route::post('/jobs/{jobId}/done', [CreativeRobotController::class, 'done'])->whereNumber('jobId');
// Доклад разведки: что робот прочитал на экране кабинета про отклонённое объявление.
// Отдельно от «готово» — у разведки другой смысл готовности: без текста её не бывает.
Route::post('/jobs/{jobId}/inspection', [CreativeRobotController::class, 'inspection'])->whereNumber('jobId');
});
// Служебный канал «Телеграм-робот кабинета МТС → Портал». Токен вместо пользователя.
// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user.
// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного входа.
Route::middleware(['tg-robot', 'admin-db'])->prefix('api/tg-robot')->group(function () {
Route::get('/next', [TgRobotController::class, 'next']);
// Номер задания — часть адреса: робот получает номера только того задания, которое
// ему выдали, а не «какого-нибудь, что сейчас в работе».
Route::get('/jobs/{jobId}/phones', [TgRobotController::class, 'phones'])->whereNumber('jobId');
Route::post('/jobs/{jobId}/done', [TgRobotController::class, 'done'])->whereNumber('jobId');
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/billing/charges')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index');
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
});
// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3).
// RLS на balance_transactions / saas_invoices требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
// Судьба последней онлайн-оплаты — кабинет показывает «оплата не завершена» вместо
// вранья «платёж обрабатывается» (инцидент 14.07.2026).
Route::get('/last-payment', 'App\Http\Controllers\Api\BillingController@lastPayment');
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');
});
// Реклама тенанта: статус рекламного кошелька для баннера «пополните
// рекламный кошелёк» (Task 9, часть B). RLS на ad_wallets требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group(function () {
Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
// Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик +
// счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача.
Route::get('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@index');
Route::post('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@store');
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@show')->whereNumber('id');
Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id');
Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id');
Route::get('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@listBanners')->whereNumber('id');
Route::post('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadBanner')->whereNumber('id');
Route::patch('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@toggleBannerIncluded')->whereNumber(['id', 'bannerId']);
Route::delete('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@deleteBanner')->whereNumber(['id', 'bannerId']);
Route::get('/campaigns/{id}/banners/{bannerId}/preview', 'App\Http\Controllers\Api\AdvertisingCampaignController@previewBanner')->whereNumber('id')->whereNumber('bannerId');
Route::post('/campaigns/{id}/banners/approve', 'App\Http\Controllers\Api\AdvertisingCampaignController@approveBanners')->whereNumber('id');
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id');
Route::post('/campaigns/{id}/submit', 'App\Http\Controllers\Api\AdvertisingCampaignController@submit')->whereNumber('id');
// «Исправить» — вернуть отклонённую кампанию в черновик. Отдельного пути запуска
// за этим не появляется: дальше клиент жмёт обычное «Запустить».
Route::post('/campaigns/{id}/revive', 'App\Http\Controllers\Api\AdvertisingCampaignController@revive')->whereNumber('id');
Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id');
Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id');
Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id');
Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']);
Route::delete('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@destroy')->whereNumber('id');
Route::post('/campaigns/{id}/phones', 'App\Http\Controllers\Api\AdvertisingCampaignController@storePhones')->whereNumber('id');
// Лента сообщений по кампании — окно передачи между Яндексом и клиентом:
// пояснения модератора, ответы клиента, служебные отметки портала.
Route::get('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@index')->whereNumber('id');
Route::post('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@store')->whereNumber('id');
Route::get('/campaigns/{id}/messages/{messageId}/file', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@file')->whereNumber(['id', 'messageId']);
});
// Клиентская СМС-рассылка (Task 7): кампании, предпросмотр сметы, контакты, шаблоны.
// RLS на client_sms_* требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/sms')->group(function () {
Route::get('/campaigns', 'App\Http\Controllers\Api\Client\ClientSmsController@index');
Route::post('/campaigns', 'App\Http\Controllers\Api\Client\ClientSmsController@store');
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\Client\ClientSmsController@show')->whereNumber('id');
Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\Client\ClientSmsController@cancel')->whereNumber('id');
// Продолжить рассылку, вставшую из-за денег (строка листа 4.13, решение В-49).
Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\Client\ClientSmsController@resume')->whereNumber('id');
// Дослать не дошедшим ОТДЕЛЬНОЙ рассылкой (строка листа 5.6, решение В-200).
Route::post('/campaigns/{id}/resend', 'App\Http\Controllers\Api\Client\ClientSmsController@resend')->whereNumber('id');
Route::post('/preview', 'App\Http\Controllers\Api\Client\ClientSmsController@preview');
// Имя отправителя и авто-СМС — ClientSmsSenderController (разрез Н.1).
Route::get('/auto-rule', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@autoRule');
// Журнал автоматических СМС за 30 дней (строка листа 4.11, решение В-102).
Route::get('/auto-log', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@autoLog');
Route::post('/auto-rule', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@saveAutoRule');
Route::get('/sender', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@sender');
Route::get('/sender/consent-form', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@consentForm');
Route::post('/sender', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@requestSender');
Route::post('/sender/disable', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@disableSender');
Route::post('/sender/enable', 'App\Http\Controllers\Api\Client\ClientSmsSenderController@enableSender');
// База номеров, стоп-лист и шаблоны — ClientSmsBaseController (разрез Н.1).
Route::get('/contacts', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@contacts');
Route::post('/contacts', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@uploadContacts');
Route::post('/contacts/file', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@uploadContactsFile');
Route::get('/contacts/example', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@contactsExample');
Route::delete('/contacts/{id}', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@deleteContact')->whereNumber('id');
Route::get('/optouts', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@optouts');
Route::post('/optouts', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@storeOptouts');
Route::post('/optouts/file', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@uploadOptoutsFile');
Route::delete('/optouts/{id}', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@deleteOptout')->whereNumber('id');
Route::get('/templates', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@templates');
Route::post('/templates', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@storeTemplate');
Route::patch('/templates/{id}', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@updateTemplate')->whereNumber('id');
Route::delete('/templates/{id}', 'App\Http\Controllers\Api\Client\ClientSmsBaseController@deleteTemplate')->whereNumber('id');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
});
// G6: публичный read-API сделок по API-ключу (middleware apikey — без sanctum/tenant).
// apiv1-rate (приёмка 21.06): throttle:api-v1 (120/мин/источник) ПЕРЕД apikey —
// прикрывает bcrypt/DB-работу аутентификации от brute/DoS (лимитер — AppServiceProvider).
Route::middleware(['throttle:api-v1', 'apikey'])->prefix('/api/v1')->group(function () {
Route::get('/deals', 'App\Http\Controllers\Api\V1\DealsController@index')->name('api.v1.deals.index');
});
// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
});
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
});
// G7-A: клиентские заявки в техподдержку.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->post('/api/support-requests', 'App\Http\Controllers\Api\SupportRequestController@store');
// G7-B: выход из режима поддержки из самого кабинета клиента.
Route::middleware('auth:sanctum')->post('/api/impersonation/leave', 'App\Http\Controllers\Api\ImpersonationController@leave');
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
//
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
// DealController, bulk (transition/destroy/restore) — в
// DealBulkActionController, export — в DealExportController.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
});
// Sprint 4 — CSV-импорт исторических лидов (ТЗ §6).
// ВАЖНО: /unknown-statuses и /unknown-statuses/resolve объявлены ДО
// /{importLog}, иначе литеральный сегмент перехватывается параметром.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
Route::get('/api/imports/unknown-statuses', 'App\Http\Controllers\Api\ImportController@unknownStatuses');
Route::post('/api/imports/unknown-statuses/resolve', 'App\Http\Controllers\Api\ImportController@resolveUnknownStatuses');
Route::get('/api/imports', 'App\Http\Controllers\Api\ImportController@index');
Route::post('/api/imports', 'App\Http\Controllers\Api\ImportController@store');
Route::get('/api/imports/{importLog}', 'App\Http\Controllers\Api\ImportController@show');
});
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
});
Route::middleware('auth:sanctum,impersonation')->group(function () {
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
});
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
// ⚠️ NewDealDialog использовал старый endpoint (tenant_id param, без auth) —
// после этой замены получит 401. Defer fix до Task 7 (frontend phase).
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projects')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index');
Route::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store');
// /bulk MUST be declared before /{id} parameterized routes so the literal
// segment matches before the regex placeholder is even considered.
Route::post('/bulk', 'App\Http\Controllers\Api\ProjectController@bulk')->name('projects.bulk');
Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+');
Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+');
Route::delete('/{id}', 'App\Http\Controllers\Api\ProjectController@destroy')->name('projects.destroy')->where('id', '[0-9]+');
Route::post('/{id}/sync', 'App\Http\Controllers\Api\ProjectController@sync')->name('projects.sync')->where('id', '[0-9]+');
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
});
// Автоподбор конкурентов — клиентский API (Task 17a).
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () {
Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state');
Route::get('/field', 'App\Http\Controllers\Api\AutopodborController@field');
Route::get('/field/duplicate-groups', 'App\Http\Controllers\Api\AutopodborController@duplicateGroups');
Route::post('/field/merge', 'App\Http\Controllers\Api\AutopodborController@mergeCompetitors');
Route::get('/field/merge-events', 'App\Http\Controllers\Api\AutopodborController@mergeEvents');
Route::post('/field/merge-events/{event}/restore', 'App\Http\Controllers\Api\AutopodborController@restoreMergeEvent')->where('event', '[0-9]+');
Route::get('/proposals', 'App\Http\Controllers\Api\AutopodborController@proposals');
Route::post('/proposals/dismiss-archived', 'App\Http\Controllers\Api\AutopodborController@dismissArchivedGroup');
Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+');
Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+');
Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+');
Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search');
Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study');
Route::post('/study/batch', 'App\Http\Controllers\Api\AutopodborController@studyBatch');
Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve');
Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects');
Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy');
Route::post('/sources/manual', 'App\Http\Controllers\Api\AutopodborController@addManualSource');
Route::post('/competitors/manual', 'App\Http\Controllers\Api\AutopodborController@manualCompetitor');
Route::post('/competitors/{competitor}/actualize', 'App\Http\Controllers\Api\AutopodborController@actualize')->where('competitor', '[0-9]+');
Route::post('/competitors/{competitor}/dismiss-actualize', 'App\Http\Controllers\Api\AutopodborController@dismissActualize')->where('competitor', '[0-9]+');
Route::patch('/competitors/{competitor}/box', 'App\Http\Controllers\Api\AutopodborController@competitorBox')->where('competitor', '[0-9]+');
Route::patch('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@updateCompetitor')->where('competitor', '[0-9]+');
Route::delete('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@destroyCompetitor')->where('competitor', '[0-9]+');
Route::patch('/sources/{source}/box', 'App\Http\Controllers\Api\AutopodborController@sourceBox')->where('source', '[0-9]+');
Route::patch('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@updateSource')->where('source', '[0-9]+');
Route::delete('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@destroySource')->where('source', '[0-9]+');
});
// Supplier-integration webhook (Plan 2/5, spec §5.1).
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
// (system_settings.supplier_ip_allowlist). Не пересекается с legacy /api/webhook/{token}.
// Secretless-вариант: аутентификация по HMAC-подписи (X-Webhook-Signature),
// чтобы секрет не светился в URL/access-логах (P2/E4). receive() c $secret=''.
Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookController@receive');
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// Свой чат: окошко на портале и лендинге (спека 2026-07-13-own-chat-widget-design §5).
// Публичный адрес — лендинг открыт всему интернету; защита от накрутки — ChatRateLimiter.
Route::post('/api/chat/message', 'App\Http\Controllers\Api\ChatController@send');
Route::get('/api/chat/{chat}/messages', 'App\Http\Controllers\Api\ChatController@messages')
->where('chat', '[a-f0-9]{32}');
// Обратный сигнал МТС о судьбе СМС (Этап 5, строка листа 5.2). Публичный, под
// маской api/webhook/* → CSRF-exempt. Защита — секрет в адресе (≥32 знаков) +
// необязательный список адресов отправителя. НАДСТРОЙКА над опросом
// `client-sms:poll-delivery`: не пришёл или не понят — судьбу доберёт опрос.
// Адрес указывается в кабинете МТС при создании API-подключения (В-207).
Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsDeliveryWebhookController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
// Публичная (без auth) тарифная сетка — для страницы цен и модерации ЮKassa.
Route::get('/api/public/pricing', 'App\Http\Controllers\Api\PublicPricingController@index');
// Маячок учёта посетителей (spec 2026-07-13). Публичный, без auth: его шлёт JS
// лендинга и портала. Робот JS не исполняет → в базу не попадает.
// throttle:track — 60/мин с адреса, защита от накрутки.
Route::post('/api/track', 'App\Http\Controllers\Api\TrackController@store')
->middleware('throttle:track');
// 2FA setup wizard — все эндпоинты под auth:sanctum (только для уже залогиненных).
Route::prefix('/api/2fa')->middleware('auth:sanctum')->group(function () {
Route::post('/init', 'App\Http\Controllers\Api\TwoFactorSetupController@init');
Route::post('/confirm', 'App\Http\Controllers\Api\TwoFactorSetupController@confirm');
Route::post('/disable', 'App\Http\Controllers\Api\TwoFactorSetupController@disable');
Route::post('/regenerate-recovery-codes', 'App\Http\Controllers\Api\TwoFactorSetupController@regenerateRecoveryCodes');
});
// SPA-страницы: каждый путь отдаёт Vue-shell (один Blade-template `welcome`).
// Vue Router (createWebHistory) разруливает /login, /register и т.п. на фронте.
//
// Регистрируем явно, а не catch-all `/{any?}` — иначе тесты, регистрирующие
// runtime routes через `Route::get(...)` в beforeEach (например _test/...),
// будут перехвачены catch-all'ом и вернут welcome view вместо expected response.
// Короткие ссылки для рассылок (spec 2026-07-13 §7). В смс дорог каждый символ,
// поэтому вместо длинного хвоста с метками даём liderra.ru/s — сервер сам подставит
// канал и дату рассылки, и учёт увидит смс отдельно от «прямых заходов».
// Дата в кампании (дд-мм) отличает рассылки друг от друга без ручной правки ссылки.
Route::get('/s', fn () => redirect()->away(
'https://liderra.ru/?utm_source=sms&utm_medium=sms&utm_campaign='.now()->format('d-m')
));
Route::get('/hh', fn () => redirect()->away(
'https://liderra.ru/?utm_source=hh&utm_medium=vacancy'
));
Route::get('/tg', fn () => redirect()->away(
'https://liderra.ru/?utm_source=tg&utm_medium=social'
));
Route::view('/', 'welcome');
Route::view('/login', 'welcome');
Route::view('/register', 'welcome');
Route::view('/forgot', 'welcome');
Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token}
Route::view('/2fa', 'welcome');
Route::view('/recovery-use', 'welcome');
Route::view('/legal/offer', 'welcome');
Route::view('/legal/privacy', 'welcome');
Route::view('/dashboard', 'welcome');
Route::view('/deals', 'welcome');
Route::view('/kanban', 'welcome');
Route::view('/projects', 'welcome');
Route::view('/billing', 'welcome');
Route::view('/settings', 'welcome');
Route::view('/reports', 'welcome');
Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт исторических лидов §6
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');
Route::view('/admin/supplier-prices', 'welcome');
Route::view('/admin/supplier-integration', 'welcome');
Route::view('/admin/impersonation', 'welcome');
Route::view('/403', 'welcome');
Route::view('/500', 'welcome');
// Страница разборов «Как это работает» — её бот присылает ГОСТЮ с лендинга (ссылка вида
// https://liderra.ru/kak-eto-rabotaet#sozdat-proekt). На боевом nginx отдаёт лендинг только
// по адресу «/», все остальные пути уходят в портал — поэтому страницу отдаём отсюда, а не
// правкой nginx. Сама страница и кадры — статика в public/ (kak-eto-rabotaet.html + guides/).
Route::get('/kak-eto-rabotaet', function () {
$page = public_path('kak-eto-rabotaet.html');
abort_unless(file_exists($page), 404);
return response()->file($page, ['Content-Type' => 'text/html; charset=utf-8']);
});
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
// beforeEach (они регистрируются в момент теста, до запроса).
Route::fallback(fn () => view('welcome'));