diff --git a/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php b/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php new file mode 100644 index 00000000..481cd26b --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php @@ -0,0 +1,110 @@ +where('is_active', true) + ->orderByDesc('role') + ->orderBy('name') + ->get(['id', 'name', 'role']) + ->all(); + + return response()->json(['data' => $data]); + } + + /** + * Приём отданных фирм. Идемпотентно по дедуп-ключу (sales_user_id, inn|phone): + * существующую карточку не плодим — освежаем payload; новую создаём stage=new. + */ + public function ingest(Request $request): JsonResponse + { + $data = $request->validate([ + 'sales_user_id' => ['required', 'integer', 'exists:sales_users,id'], + 'firms' => ['required', 'array'], + 'firms.*.firm_name' => ['required', 'string', 'max:500'], + 'firms.*.city' => ['nullable', 'string', 'max:255'], + 'firms.*.phone' => ['nullable', 'string', 'max:64'], + 'firms.*.site' => ['nullable', 'string', 'max:500'], + 'firms.*.inn' => ['nullable', 'string', 'max:20'], + 'firms.*.rating_label' => ['nullable', 'string', 'max:32'], + 'firms.*.payload' => ['nullable', 'array'], + ]); + + $salesUserId = (int) $data['sales_user_id']; + $assignedBy = SalesUser::query()->where('role', 'head')->value('id'); + + $created = 0; + $skipped = 0; + + foreach ($data['firms'] as $firm) { + $inn = $firm['inn'] ?? null; + $phone = $firm['phone'] ?? null; + $payload = $firm['payload'] ?? []; + + $existing = $this->findDuplicate($salesUserId, $inn, $phone); + if ($existing !== null) { + $existing->payload = $payload; + $existing->save(); + $skipped++; + + continue; + } + + SalesProspect::create([ + 'sales_user_id' => $salesUserId, + 'assigned_by' => $assignedBy, + 'stage' => 'new', + 'firm_name' => $firm['firm_name'], + 'city' => $firm['city'] ?? null, + 'phone' => $phone, + 'site' => $firm['site'] ?? null, + 'inn' => $inn, + 'rating_label' => $firm['rating_label'] ?? null, + 'payload' => $payload, + ]); + $created++; + } + + return response()->json(['created' => $created, 'skipped' => $skipped]); + } + + /** + * Существующая карточка этой фирмы у этого менеджера: по ИНН, иначе по телефону. + * Если ни ИНН, ни телефона — считаем новой (дедуп невозможен). + */ + private function findDuplicate(int $salesUserId, ?string $inn, ?string $phone): ?SalesProspect + { + if ($inn !== null && $inn !== '') { + return SalesProspect::query() + ->where('sales_user_id', $salesUserId)->where('inn', $inn)->first(); + } + if ($phone !== null && $phone !== '') { + return SalesProspect::query() + ->where('sales_user_id', $salesUserId)->where('phone', $phone)->first(); + } + + return null; + } +} diff --git a/app/app/Http/Middleware/SalesIntegrationToken.php b/app/app/Http/Middleware/SalesIntegrationToken.php new file mode 100644 index 00000000..4cba268c --- /dev/null +++ b/app/app/Http/Middleware/SalesIntegrationToken.php @@ -0,0 +1,34 @@ +header('X-Sales-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен.'); + } + + return $next($request); + } +} diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index ba0e7391..a4176fbc 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -4,6 +4,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\SalesIntegrationToken; use App\Http\Middleware\SetTenantContext; use App\Http\Middleware\UseAdminConnection; use Illuminate\Auth\AuthenticationException; @@ -31,6 +32,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'saas-admin' => EnsureSaasAdmin::class, 'admin-db' => UseAdminConnection::class, 'sales-portal' => EnsureSalesUser::class, + 'sales-integration' => SalesIntegrationToken::class, 'apikey' => ApiKeyAuth::class, ]); diff --git a/app/config/sales.php b/app/config/sales.php new file mode 100644 index 00000000..4192a2b3 --- /dev/null +++ b/app/config/sales.php @@ -0,0 +1,18 @@ + env('SALES_INTEGRATION_TOKEN'), +]; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 414a9508..5f5eb686 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3996,6 +3996,24 @@ parameters: count: 2 path: tests/Feature/Sales/SalesProspectApiTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Sales/SalesIntegrationApiTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 6 + path: tests/Feature/Sales/SalesIntegrationApiTest.php + + - + message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\|null\>\:\:\$not\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Sales/SalesIntegrationApiTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index bf95cdf9..d32ac37f 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -5,6 +5,7 @@ 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; @@ -294,6 +295,13 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales // 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']); +}); + // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). // RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant // видит только свои lead_charges. Pagination 20/page, фильтры period/source. diff --git a/app/tests/Feature/Sales/SalesIntegrationApiTest.php b/app/tests/Feature/Sales/SalesIntegrationApiTest.php new file mode 100644 index 00000000..83fa4bb0 --- /dev/null +++ b/app/tests/Feature/Sales/SalesIntegrationApiTest.php @@ -0,0 +1,120 @@ + 'U '.uniqid(), 'email' => 'in'.uniqid().'@s.local', + 'password' => bcrypt('secret'), 'role' => $role, 'is_active' => $active, + ]); +} + +function int_head(): SalesUser +{ + return SalesUser::where('role', 'head')->first() ?? int_user('head'); +} + +beforeEach(function () { + config(['sales.integration_token' => 'TESTTOKEN']); +}); + +// ── сервис-токен ─────────────────────────────────────────────────────────────── + +test('без токена сервис-канал → 401', function () { + $this->getJson('/api/sales/integration/managers')->assertUnauthorized(); +}); + +test('неверный токен → 401', function () { + $this->getJson('/api/sales/integration/managers', ['X-Sales-Token' => 'WRONG']) + ->assertUnauthorized(); +}); + +test('пустой конфиг-токен → 401 даже при совпадении пустого', function () { + config(['sales.integration_token' => null]); + $this->getJson('/api/sales/integration/managers', ['X-Sales-Token' => '']) + ->assertUnauthorized(); +}); + +// ── managers ──────────────────────────────────────────────────────────────────── + +test('managers отдаёт активных менеджеров (вкл. начальника), неактивных нет', function () { + $head = int_head(); + $m = int_user('manager', true); + $off = int_user('manager', false); + + $res = $this->getJson('/api/sales/integration/managers', ['X-Sales-Token' => 'TESTTOKEN']); + + $res->assertOk(); + $ids = collect($res->json('data'))->pluck('id'); + expect($ids)->toContain($head->id)->toContain($m->id)->not->toContain($off->id); +}); + +// ── ingest ──────────────────────────────────────────────────────────────────── + +test('ingest создаёт карточку stage=new с payload; повтор по inn → skipped, payload обновлён', function () { + $head = int_head(); + $mgr = int_user('manager'); + + $body = ['sales_user_id' => $mgr->id, 'firms' => [[ + 'firm_name' => 'ООО Ромашка', 'city' => 'Ростов', 'phone' => '+7863', 'site' => 'romashka.ru', + 'inn' => '6161234567', 'rating_label' => 'горячая', 'payload' => ['director' => 'Иванов'], + ]]]; + + $r1 = $this->postJson('/api/sales/integration/prospects', $body, ['X-Sales-Token' => 'TESTTOKEN']); + $r1->assertOk(); + expect($r1->json('created'))->toBe(1); + $p = SalesProspect::where('sales_user_id', $mgr->id)->where('inn', '6161234567')->first(); + expect($p->stage)->toBe('new'); + expect($p->payload['director'])->toBe('Иванов'); + + // Повтор той же фирмы → не плодит, payload обновляется. + $body['firms'][0]['payload'] = ['director' => 'Петров']; + $r2 = $this->postJson('/api/sales/integration/prospects', $body, ['X-Sales-Token' => 'TESTTOKEN']); + $r2->assertOk(); + expect($r2->json('skipped'))->toBe(1); + expect(SalesProspect::where('sales_user_id', $mgr->id)->where('inn', '6161234567')->count())->toBe(1); + expect(SalesProspect::where('sales_user_id', $mgr->id)->where('inn', '6161234567')->first()->payload['director']) + ->toBe('Петров'); +}); + +test('ingest дедуп по phone, когда inn пуст', function () { + $mgr = int_user('manager'); + int_head(); + + $body = ['sales_user_id' => $mgr->id, 'firms' => [[ + 'firm_name' => 'Без ИНН', 'city' => 'Ростов', 'phone' => '+79990001122', 'site' => null, + 'inn' => null, 'rating_label' => null, 'payload' => [], + ]]]; + + $this->postJson('/api/sales/integration/prospects', $body, ['X-Sales-Token' => 'TESTTOKEN'])->assertOk(); + $r2 = $this->postJson('/api/sales/integration/prospects', $body, ['X-Sales-Token' => 'TESTTOKEN']); + + expect($r2->json('skipped'))->toBe(1); + expect(SalesProspect::where('sales_user_id', $mgr->id)->where('phone', '+79990001122')->count())->toBe(1); +}); + +test('ingest: невалидный sales_user_id → 422', function () { + int_head(); + $this->postJson('/api/sales/integration/prospects', [ + 'sales_user_id' => 999999, 'firms' => [['firm_name' => 'X']], + ], ['X-Sales-Token' => 'TESTTOKEN'])->assertStatus(422); +}); + +test('ingest проставляет assigned_by = начальник', function () { + $head = int_head(); + $mgr = int_user('manager'); + $this->postJson('/api/sales/integration/prospects', [ + 'sales_user_id' => $mgr->id, + 'firms' => [['firm_name' => 'A', 'inn' => '7700000001', 'payload' => []]], + ], ['X-Sales-Token' => 'TESTTOKEN'])->assertOk(); + + $p = SalesProspect::where('sales_user_id', $mgr->id)->where('inn', '7700000001')->first(); + expect($p->assigned_by)->toBe($head->id); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 52646452..e0240c61 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-15T13:51:15.392Z +Last updated: 2026-07-15T14:53:15.325Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3488 | MsMpEng | 6.99ч | 0.0ч | -| 9756 | Code | 3.24ч | NaNч | -| 1320 | svchost | 1.34ч | 0.0ч | +| 3488 | MsMpEng | 7.17ч | 0.0ч | +| 9756 | Code | 3.40ч | NaNч | +| 1320 | svchost | 1.40ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-07-15-sales-prospects-etap2-finder-to-portal.md b/docs/superpowers/plans/2026-07-15-sales-prospects-etap2-finder-to-portal.md new file mode 100644 index 00000000..3f512cb5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-sales-prospects-etap2-finder-to-portal.md @@ -0,0 +1,141 @@ +# Этап 2 «Поиск → Портал» — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Начальник в Python-поиске отмечает фирмы и «отдаёт менеджеру» → они прилетают карточками в воронку портала с ПОЛНЫМ снимком данных поиска. + +**Architecture:** Портал публикует сервис-канал (2 эндпоинта под токеном, не под пользователем): `GET /api/sales/integration/managers`, `POST /api/sales/integration/prospects`. Поиск дергает их своим сервис-клиентом. Дедуп по `(sales_user_id, inn|phone)`. + +**Tech Stack:** Laravel 13 (Pest) + Python/FastAPI (pytest). Источник дизайна — spec `2026-07-15-sales-prospects-kanban-design.md` §7/§10/§11.2/§13. + +--- + +## File Structure + +**Портал (`app/`):** +- `app/Http/Middleware/SalesIntegrationToken.php` — проверка заголовка `X-Sales-Token` против `config('sales.integration_token')`. +- `config/sales.php` — `integration_token` из env `SALES_INTEGRATION_TOKEN`. +- `app/Http/Controllers/Api/Sales/SalesIntegrationController.php` — `managers()`, `ingest()`. +- `routes/web.php` — новая группа `['admin-db','sales-integration']` prefix `api/sales/integration`. +- `bootstrap/app.php` — alias `sales-integration`. +- Тесты: `app/tests/Feature/Sales/SalesIntegrationApiTest.php`. + +**Поиск (`моя/sales-finder/`):** +- `salesfinder/models.py` — в `Firm` добавить `assigned_manager_id`, `assigned_manager_name`. +- `salesfinder/portal.py` — клиент портала: `get_managers(base,token)`, `send_prospects(base,token,manager_id,firms)`. +- `web/app.py` — эндпоинт `POST /view/{rid}/assign`; helper `_portal_cfg()`. +- `web/ui.py` — колонка «Менеджер» перед «Оценка»; кнопка «Отдать менеджеру» + `` (из менеджеров портала, кэш) + второй submit-кнопкой `formaction=/view/{rid}/assign` внутри той же формы с чекбоксами. Колонка «Менеджер» (`assigned_manager_name` или «—») ПЕРЕД «Оценка». Обёртка таблицы с `margin-left` (отступ вправо). +`/view/{rid}/assign`: `sel`+`manager_id` → выбрать фирмы по индексам → `portal.send_prospects` → на успех проставить `assigned_manager_id/name` в SQLite (по имени из списка менеджеров) → redirect. Портал недоступен/`PortalError` → страница с понятной ошибкой, ничего не помечаем. + +- [ ] **Step 1 (RED):** тест «assign с мок-клиентом: выбранные фирмы уходят в send_prospects с manager_id; на успех строки помечены (STORE обновлён)». +- [ ] **Step 2 (RED):** тест «PortalError → страница ошибки, строки НЕ помечены». +- [ ] **Step 3 (RED):** тест «results_html содержит колонку Менеджер и кнопку Отдать менеджеру при наличии менеджеров». +- [ ] **Step 4:** реализовать endpoint + ui (managers тянутся через `portal.get_managers`, ошибка → кнопка disabled). +- [ ] **Step 5 (GREEN):** прогнать pytest. +- [ ] **Step 6:** commit. + +--- + +## Выкат (с разрешения владельца) + +- [ ] Сгенерировать общий секрет; на проде: `secrets/portal_service_token.txt` (600) у поиска (`/opt/sales-finder`), `SALES_INTEGRATION_TOKEN` в `app/.env` портала; `SF_PORTAL_URL=https://lk.liderra.ru` в окружении службы поиска. +- [ ] Портал — `bin/deploy-source-edit.sh` (миграций нет); поиск — обновить `/opt/sales-finder` + `systemctl restart sales-finder`. +- [ ] Живой смоук: в поиске отдать 1 фирму начальнику → появилась карточка в воронке с полным payload; колонка «Менеджер» проставлена. + +## Тесты (гейты) +- Портал: `SalesIntegrationApiTest` (auth, managers, ingest+дедуп) зелёный; Larastan 0. +- Поиск: `test_portal_client`, `test_assign_endpoint` зелёные (`pytest`). +- RLS-review не нужен (миграций нет). CHANGELOG_schema не трогаем.