feat(sales): Этап 2 — сервис-канал поиск→портал (managers + ingest)

Портал публикует /api/sales/integration/{managers,prospects} под сервис-токеном
(X-Sales-Token, config sales.integration_token). ingest создаёт карточки stage=new
с полным payload, дедуп по (sales_user_id, inn|phone), assigned_by=начальник.
Гейты: 8/8 Pest, Larastan 0. Финдер-сторона — следующим коммитом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-15 18:13:59 +03:00
parent 2f6e88e784
commit 16aa00a0e4
9 changed files with 455 additions and 4 deletions
@@ -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` — колонка «Менеджер» перед «Оценка»; кнопка «Отдать менеджеру» + `<select>`; отступ таблицы.
- Тесты: `tests/test_portal_client.py`, `tests/test_assign_endpoint.py`.
---
## Портал (Laravel)
### Task 1: Сервис-токен middleware + config
**Files:**
- Create: `app/config/sales.php`
- Create: `app/app/Http/Middleware/SalesIntegrationToken.php`
- Modify: `app/bootstrap/app.php` (alias)
- Test: `app/tests/Feature/Sales/SalesIntegrationApiTest.php`
- [ ] **Step 1 (RED):** тест «без токена → 401; с неверным → 401; с верным → проходит к контроллеру».
Устанавливаем `config(['sales.integration_token' => 'T'])` в тесте; бьём `getJson('/api/sales/integration/managers', ['X-Sales-Token'=>'T'])` → не 401.
- [ ] **Step 2:** `config/sales.php`: `return ['integration_token' => env('SALES_INTEGRATION_TOKEN')];`
- [ ] **Step 3:** middleware: если `config('sales.integration_token')` пуст ИЛИ заголовок `X-Sales-Token` не совпадает (hash_equals) → `abort(401)`. Иначе next.
- [ ] **Step 4:** alias `sales-integration` в bootstrap/app.php рядом с `admin-db`/`sales-portal`.
- [ ] **Step 5 (GREEN):** прогнать тест.
- [ ] **Step 6:** commit.
### Task 2: GET /integration/managers
**Files:**
- Create: `app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php`
- Modify: `app/routes/web.php`
- Test: `SalesIntegrationApiTest.php`
- [ ] **Step 1 (RED):** тест «возвращает активных sales_users (id,name,role), включая head; неактивных нет».
- [ ] **Step 2:** роут-группа + `managers()`: `SalesUser::where('is_active',true)->orderByDesc('role')->orderBy('name')->get(['id','name','role'])``{data:[...]}`.
- [ ] **Step 3 (GREEN):** прогнать.
- [ ] **Step 4:** commit.
### Task 3: POST /integration/prospects (ingest + дедуп)
**Files:**
- Modify: `SalesIntegrationController.php`
- Test: `SalesIntegrationApiTest.php`
Тело: `{sales_user_id:int, firms:[{firm_name, city, phone, site, inn, rating_label, payload}]}`.
Логика на каждую фирму:
- определить дедуп-ключ: если `inn` есть → `where(sales_user_id, inn)`; иначе если `phone``where(sales_user_id, phone)`; иначе всегда создаём.
- существует → обновить `payload` (свежий снимок), НЕ менять stage → `skipped++`.
- нет → `SalesProspect::create([... stage:'new', assigned_by: head_id, payload ...])``created++`.
- `assigned_by` = `SalesUser::where('role','head')->value('id')` (поиск — кабинет начальника).
Ответ: `{created:int, skipped:int}`.
- [ ] **Step 1 (RED):** тест «создаёт stage=new + payload; повтор той же (по inn) → skipped, payload обновлён, карточка одна; невалидный sales_user_id → 422».
- [ ] **Step 2 (RED):** тест «дедуп по phone, когда inn пуст».
- [ ] **Step 3:** реализовать `ingest()` с валидацией (`sales_user_id`=exists sales_users; `firms`=array; `firms.*.firm_name`=required).
- [ ] **Step 4 (GREEN):** прогнать; обновить phpstan-baseline (actingAs/getJson/postJson новых тестов).
- [ ] **Step 5:** commit.
---
## Поиск (Python)
### Task 4: Firm — поля назначения
**Files:**
- Modify: `salesfinder/models.py`
- Test: `tests/test_portal_client.py` (round-trip asdict)
- [ ] **Step 1 (RED):** тест «Firm(assigned_manager_id=7).asdict имеет ключ, дефолт None».
- [ ] **Step 2:** добавить `assigned_manager_id: Optional[int] = None`, `assigned_manager_name: Optional[str] = None`.
- [ ] **Step 3 (GREEN):** прогнать. (Firm(**row) в app.py уже примет новые ключи.)
- [ ] **Step 4:** commit.
### Task 5: Клиент портала `salesfinder/portal.py`
**Files:**
- Create: `salesfinder/portal.py`
- Test: `tests/test_portal_client.py`
`get_managers(base, token, get=requests.get)` → список dict; шлёт `X-Sales-Token`; на не-2xx → `PortalError`.
`send_prospects(base, token, manager_id, firms, post=requests.post)` → строит `{sales_user_id, firms:[firm_payload]}`, где `firm_payload` = `{firm_name:f.name, city, phone, site:f.domain, inn, rating_label:heat_label(f), payload: asdict(f без служебных)}`. Возвращает `{created,skipped}`; не-2xx → `PortalError`.
- [ ] **Step 1 (RED):** тест с фейковым `post` — проверяет URL, заголовок токена, тело (sales_user_id + firms с payload из полей фирмы), парсинг ответа.
- [ ] **Step 2 (RED):** тест «не-2xx → PortalError».
- [ ] **Step 3:** реализовать модуль (payload кладёт ВСЕ поля Firm — для замечания №1).
- [ ] **Step 4 (GREEN):** прогнать.
- [ ] **Step 5:** commit.
### Task 6: UI поиска — кнопка «Отдать», колонка «Менеджер», отступ, эндпоинт
**Files:**
- Modify: `web/app.py` (эндпоинт `/view/{rid}/assign`, `_portal_cfg()`)
- Modify: `web/ui.py` (колонка, кнопки, select, отступ)
- Test: `tests/test_assign_endpoint.py`
`_portal_cfg()``(base, token)` из env `SF_PORTAL_URL` + `secrets/portal_service_token.txt`. Пусто → назначение недоступно.
`results_html`: в шапку `<select name=manager_id>` (из менеджеров портала, кэш) + второй 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 не трогаем.