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,110 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Сервис-канал «Поиск Портал» (под middleware ['admin-db','sales-integration']).
*
* GET /api/sales/integration/managers список менеджеров для выпадашки в поиске.
* POST /api/sales/integration/prospects приём отданных фирм в воронку (stage=new).
*
* Не пользовательский auth только сервис-токен (см. SalesIntegrationToken).
* Дизайн: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §7.
*/
class SalesIntegrationController extends Controller
{
/** Активные менеджеры + начальник (для выбора «кому отдать»). */
public function managers(): JsonResponse
{
$data = SalesUser::query()
->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;
}
}
@@ -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/integration/*).
*
* Проверяет заголовок X-Sales-Token против config('sales.integration_token').
* Это НЕ пользовательская аутентификация: поиск ходит от своего имени, не от
* менеджера. Пустой конфиг-токен канал закрыт (401), чтобы случайно не открыть
* его без секрета.
*
* Дизайн: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §7.
*/
class SalesIntegrationToken
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('sales.integration_token', '');
$given = (string) $request->header('X-Sales-Token', '');
if ($expected === '' || ! hash_equals($expected, $given)) {
abort(401, 'Неверный сервис-токен.');
}
return $next($request);
}
}
+2
View File
@@ -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,
]);
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Сервис-токен интеграции «Поиск Портал»
|--------------------------------------------------------------------------
|
| Общий секрет между Python-поиском (secrets/portal_service_token.txt) и
| порталом. Проверяется middleware SalesIntegrationToken на сервис-канале
| /api/sales/integration/*. Пусто канал закрыт (401 на любой запрос).
| Дизайн: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §7.
|
*/
'integration_token' => env('SALES_INTEGRATION_TOKEN'),
];
+18
View File
@@ -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\<Illuminate\\Support\\Collection\<\(int\|string\), mixed\>\|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
+8
View File
@@ -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.
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function int_user(string $role = 'manager', bool $active = true): SalesUser
{
return SalesUser::create([
'name' => '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);
});
+4 -4
View File
@@ -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-сессий.
@@ -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 не трогаем.