ac186593f2
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.
Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.
Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
batch update 3 сделок + 3 ActivityLog с правильным context /
NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
только свой) / 422 пустой массив ids.
Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
N из M.», на fail — warning toast + локальный update НЕ откатывается.
Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.
Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.
PHPStan baseline регенерирован. cspell-glossary +апдейт*.
Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).
Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
394 lines
17 KiB
PHP
394 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\ActivityLog;
|
||
use App\Models\Deal;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierLeadCost;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\SupplierResolver;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Сделки — manual create/list через REST API.
|
||
*
|
||
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
|
||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||
*
|
||
* На MVP без auth-middleware (multi-tenant контекст резолвится по
|
||
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
|
||
* → tenant_id из request()->user()->tenant_id; user ID для manager/audit.
|
||
*
|
||
* Manual-create отличается от webhook'а:
|
||
* - source_crm_id = NULL (не из webhook).
|
||
* - НЕ списывает баланс (manual leads — не закупка у supplier'а).
|
||
* - НЕ создаёт SupplierLeadCost / BalanceTransaction.
|
||
* - Антифрод-дедуп НЕ применяется (manual — admin знает что вводит).
|
||
* - Project резолвится / создаётся по name (как и в webhook).
|
||
* - Если manager_id передан — должен принадлежать tenant'у (FK guard).
|
||
*/
|
||
class DealController extends Controller
|
||
{
|
||
/**
|
||
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
||
*
|
||
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
|
||
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
|
||
*
|
||
* Сортировка: ORDER BY received_at DESC (свежие сверху). На MVP без
|
||
* курсорной пагинации — limit/offset простой OFFSET-paging достаточен
|
||
* для UI-разводки (на prod при росте datasets — переход на keyset на
|
||
* (received_at, id)).
|
||
*
|
||
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
|
||
* tenant_id (без auth — на MVP, на prod — middleware).
|
||
*/
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->query('tenant_id', '0');
|
||
if ($tenantId < 1) {
|
||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||
}
|
||
|
||
$tenant = Tenant::find($tenantId);
|
||
if ($tenant === null) {
|
||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||
}
|
||
|
||
$statuses = (array) $request->query('status_in', []);
|
||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
|
||
$search = trim((string) $request->query('search', ''));
|
||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||
$offset = max(0, (int) $request->query('offset', '0'));
|
||
|
||
[$deals, $total] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset) {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||
|
||
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
||
// через `postgres` superuser RLS обходится BYPASSRLS, app-фильтр
|
||
// гарантирует изоляцию даже при misconfig RLS.
|
||
$query = Deal::query()
|
||
->where('tenant_id', $tenantId)
|
||
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
||
|
||
if ($statuses !== []) {
|
||
$query->whereIn('status', array_filter($statuses, 'is_string'));
|
||
}
|
||
if ($projectId !== null) {
|
||
$query->where('project_id', $projectId);
|
||
}
|
||
if ($managerId !== null) {
|
||
$query->where('manager_id', $managerId);
|
||
}
|
||
if ($search !== '') {
|
||
$like = '%'.$search.'%';
|
||
$query->where(function ($q) use ($like) {
|
||
$q->where('phone', 'ilike', $like)
|
||
->orWhere('contact_name', 'ilike', $like);
|
||
});
|
||
}
|
||
|
||
$total = (clone $query)->count();
|
||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||
->limit($limit)->offset($offset)->get();
|
||
|
||
return [$rows, $total];
|
||
});
|
||
|
||
return response()->json([
|
||
'deals' => $deals->map(fn (Deal $d) => [
|
||
'id' => $d->id,
|
||
'tenant_id' => $d->tenant_id,
|
||
'project_id' => $d->project_id,
|
||
'project_name' => $d->project?->name,
|
||
'phone' => $d->phone,
|
||
'contact_name' => $d->contact_name,
|
||
'status' => $d->status,
|
||
'manager_id' => $d->manager_id,
|
||
'manager_name' => $d->manager
|
||
? ManagerController::formatName($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||
: null,
|
||
'manager_initials' => $d->manager
|
||
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||
: null,
|
||
'received_at' => $d->received_at?->toIso8601String(),
|
||
]),
|
||
'total' => $total,
|
||
'limit' => $limit,
|
||
'offset' => $offset,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST /api/deals/transition — bulk status-update для выбранных сделок.
|
||
*
|
||
* Body: {tenant_id, ids: [int...], status: slug}.
|
||
* Используется в DealsView bulk-actions (sticky-bar «Сменить статус»).
|
||
*
|
||
* Валидация:
|
||
* - status должен существовать в lead_statuses (slug).
|
||
* - ids проверяются на принадлежность tenant'у через `where(tenant_id)`.
|
||
*
|
||
* RLS + defense-in-depth: SET LOCAL + явный where(tenant_id) — в тестах
|
||
* через postgres superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
|
||
* что чужие id'шники не апдейтятся.
|
||
*
|
||
* На MVP без проверки allowed-переходов между статусами (любой → любой);
|
||
* production: валидировать через lead_status_transitions (если введём).
|
||
* История смен пишется в ActivityLog как `deal.status_changed`.
|
||
*/
|
||
public function transition(Request $request): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'tenant_id' => 'required|integer|min:1',
|
||
'ids' => 'required|array|min:1|max:1000',
|
||
'ids.*' => 'integer|min:1',
|
||
'status' => 'required|string|max:50',
|
||
]);
|
||
|
||
$tenant = Tenant::find($validated['tenant_id']);
|
||
if ($tenant === null) {
|
||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||
}
|
||
|
||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||
if (! $statusExists) {
|
||
return response()->json([
|
||
'message' => 'Неизвестный статус.',
|
||
'errors' => ['status' => ['Slug не найден в lead_statuses.']],
|
||
], 422);
|
||
}
|
||
|
||
$updated = DB::transaction(function () use ($validated, $tenant) {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||
|
||
$deals = Deal::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->whereIn('id', $validated['ids'])
|
||
->get();
|
||
|
||
$count = 0;
|
||
foreach ($deals as $deal) {
|
||
if ($deal->status === $validated['status']) {
|
||
continue; // не пишем NO-OP в ActivityLog
|
||
}
|
||
$previous = $deal->status;
|
||
$deal->status = $validated['status'];
|
||
$deal->save();
|
||
|
||
ActivityLog::create([
|
||
'tenant_id' => $tenant->id,
|
||
'user_id' => null, // на prod — request()->user()->id
|
||
'deal_id' => $deal->id,
|
||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||
'context' => [
|
||
'from' => $previous,
|
||
'to' => $validated['status'],
|
||
'source' => 'bulk',
|
||
],
|
||
]);
|
||
$count++;
|
||
}
|
||
|
||
return $count;
|
||
});
|
||
|
||
return response()->json([
|
||
'updated' => $updated,
|
||
'requested' => count($validated['ids']),
|
||
'status' => $validated['status'],
|
||
]);
|
||
}
|
||
|
||
/** POST /api/deals — manual create */
|
||
public function store(Request $request): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'tenant_id' => 'required|integer|min:1',
|
||
'project_name' => 'required|string|max:255',
|
||
'phone' => 'required|string|max:20',
|
||
'contact_name' => 'nullable|string|max:255',
|
||
'status' => 'nullable|string|max:50',
|
||
'manager_id' => 'nullable|integer|min:1',
|
||
'comment' => 'nullable|string|max:5000',
|
||
]);
|
||
|
||
$tenant = Tenant::find($validated['tenant_id']);
|
||
if ($tenant === null) {
|
||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||
}
|
||
|
||
// Manager FK guard: если manager_id передан, он должен принадлежать
|
||
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
|
||
if (isset($validated['manager_id'])) {
|
||
$managerExists = User::query()
|
||
->where('id', $validated['manager_id'])
|
||
->where('tenant_id', $tenant->id)
|
||
->whereNull('deleted_at')
|
||
->where('is_active', true)
|
||
->exists();
|
||
if (! $managerExists) {
|
||
return response()->json([
|
||
'message' => 'Менеджер не найден в этом тенанте.',
|
||
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||
], 422);
|
||
}
|
||
}
|
||
|
||
$statusSlug = $validated['status'] ?? 'new';
|
||
|
||
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
|
||
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||
|
||
$project = Project::firstOrCreate(
|
||
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
|
||
['type' => 'manual'],
|
||
);
|
||
|
||
$deal = Deal::create([
|
||
'tenant_id' => $tenant->id,
|
||
'source_crm_id' => null, // manual
|
||
'project_id' => $project->id,
|
||
'phone' => $validated['phone'],
|
||
'status' => $statusSlug,
|
||
'contact_name' => $validated['contact_name'] ?? null,
|
||
'comment' => $validated['comment'] ?? null,
|
||
'manager_id' => $validated['manager_id'] ?? null,
|
||
'assigned_at' => isset($validated['manager_id']) ? now() : null,
|
||
'received_at' => now(),
|
||
]);
|
||
|
||
// SupplierLeadCost для manual-leads — если у проекта есть активный
|
||
// supplier через project_suppliers (m2m). Manual НЕ списывает
|
||
// баланс (Ю-2: реселлерская модель работает только при закупке у
|
||
// supplier'а), но cost-аналитика всё равно нужна — owner проекта
|
||
// мог самостоятельно купить лид и ввести руками.
|
||
$resolver = app(SupplierResolver::class);
|
||
$supplierId = $resolver->resolveForProject($project);
|
||
if ($supplierId !== null) {
|
||
SupplierLeadCost::create([
|
||
'deal_id' => $deal->id,
|
||
'received_at' => $deal->received_at,
|
||
'supplier_id' => $supplierId,
|
||
'cost_rub' => $resolver->costRubSnapshot($supplierId),
|
||
'supplier_lead_id' => null, // manual: нет внешнего id
|
||
'created_at' => now(),
|
||
]);
|
||
}
|
||
|
||
ActivityLog::create([
|
||
'tenant_id' => $tenant->id,
|
||
'user_id' => null, // на prod — request()->user()->id
|
||
'deal_id' => $deal->id,
|
||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||
'context' => ['source' => 'manual'],
|
||
]);
|
||
|
||
return $deal;
|
||
});
|
||
|
||
return response()->json([
|
||
'deal' => [
|
||
'id' => $deal->id,
|
||
'tenant_id' => $deal->tenant_id,
|
||
'project_id' => $deal->project_id,
|
||
'phone' => $deal->phone,
|
||
'status' => $deal->status,
|
||
'contact_name' => $deal->contact_name,
|
||
'manager_id' => $deal->manager_id,
|
||
'received_at' => $deal->received_at->toIso8601String(),
|
||
],
|
||
'message' => 'Сделка создана.',
|
||
], 201);
|
||
}
|
||
|
||
/**
|
||
* POST /api/deals/export — CSV-export выбранных сделок.
|
||
*
|
||
* Body: {tenant_id, ids: [int...]}.
|
||
* Возвращает text/csv attachment (UTF-8 + BOM, ; разделитель).
|
||
*
|
||
* На MVP без auth — id'шники и tenant_id переданы клиентом, RLS-обёртка
|
||
* на SELECT гарантирует что чужие сделки не попадут (даже если клиент
|
||
* подсунет чужие id, RLS их отфильтрует).
|
||
*/
|
||
public function export(Request $request): Response
|
||
{
|
||
$validated = $request->validate([
|
||
'tenant_id' => 'required|integer|min:1',
|
||
'ids' => 'required|array|min:1|max:10000',
|
||
'ids.*' => 'integer|min:1',
|
||
]);
|
||
|
||
$tenant = Tenant::find($validated['tenant_id']);
|
||
if ($tenant === null) {
|
||
abort(404, 'Тенант не найден.');
|
||
}
|
||
|
||
$rows = DB::transaction(function () use ($validated, $tenant) {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||
|
||
return Deal::query()
|
||
->whereIn('id', $validated['ids'])
|
||
->orderBy('id')
|
||
->get([
|
||
'id', 'phone', 'status', 'contact_name',
|
||
'project_id', 'manager_id', 'received_at',
|
||
]);
|
||
});
|
||
|
||
$csv = $this->buildCsv($rows->toArray());
|
||
$filename = 'deals_export_'.now()->format('Y-m-d').'.csv';
|
||
|
||
return response($csv, 200, [
|
||
'Content-Type' => 'text/csv; charset=utf-8',
|
||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $rows
|
||
*/
|
||
private function buildCsv(array $rows): string
|
||
{
|
||
$headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
|
||
$lines = [implode(';', $headers)];
|
||
|
||
foreach ($rows as $r) {
|
||
$lines[] = implode(';', [
|
||
$this->escape((string) $r['id']),
|
||
$this->escape((string) ($r['contact_name'] ?? '')),
|
||
$this->escape((string) $r['phone']),
|
||
$this->escape((string) $r['status']),
|
||
$this->escape((string) $r['project_id']),
|
||
$this->escape((string) ($r['manager_id'] ?? '')),
|
||
$this->escape((string) $r['received_at']),
|
||
]);
|
||
}
|
||
|
||
// BOM (U+FEFF) — Excel правильно распознаёт UTF-8 в CSV.
|
||
return "\u{FEFF}".implode("\r\n", $lines);
|
||
}
|
||
|
||
private function escape(string $value): string
|
||
{
|
||
if (str_contains($value, ';') || str_contains($value, '"') || str_contains($value, "\n")) {
|
||
return '"'.str_replace('"', '""', $value).'"';
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
}
|