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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 не трогаем.
|
||||
Reference in New Issue
Block a user