Files
portal/app/app/Http/Controllers/Api/DealController.php
T

509 lines
23 KiB
PHP
Raw Normal View History

<?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\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Сделки — single-resource CRUD через REST API (index/show/store/update).
*
* Sprint 3 Phase A (audit O-refactor-01): bulk transition/destroy/restore
* вынесены в `DealBulkActionController`, `export()` — в `DealExportController`.
* Этот класс остаётся только для CRUD по одной записи.
*
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* 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?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).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$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'));
$onlyDeleted = $request->boolean('only_deleted');
$countOnly = $request->boolean('count_only');
$cursorRaw = (string) $request->query('cursor', '');
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
// O(1) на любой глубине. Без cursor — старое OFFSET-поведение (backward-compat).
$cursor = null;
if ($cursorRaw !== '') {
$decoded = base64_decode($cursorRaw, true);
if ($decoded === false) {
return response()->json(['message' => 'Невалидный cursor (не base64).'], 422);
}
$parsed = json_decode($decoded, true);
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
return response()->json(['message' => 'Невалидный cursor (нет полей r/i).'], 422);
}
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
}
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
// через `postgres` superuser RLS обходится BYPASSRLS, app-фильтр
// гарантирует изоляцию даже при misconfig RLS.
//
// only_deleted=true: «Корзина» — показываем только soft-deleted.
// withTrashed() обходит global scope SoftDeletes; явный
// whereNotNull('deleted_at') фильтрует только удалённые.
$query = Deal::query()
->where('tenant_id', $tenantId)
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
}
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);
});
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
if ($countOnly) {
return [collect(), $query->count(), null];
}
if ($cursor !== null) {
// Keyset: PG row constructor через индекс на (received_at DESC, id DESC).
// Не считаем total (дорого без COUNT(*); клиент при необходимости
// вызывает endpoint без cursor для total на первой странице).
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
->limit($limit + 1)->get();
$hasNext = $rows->count() > $limit;
if ($hasNext) {
$rows = $rows->slice(0, $limit)->values();
}
$next = null;
if ($hasNext && $rows->isNotEmpty()) {
$last = $rows->last();
$next = base64_encode((string) json_encode([
'r' => $last->received_at?->toIso8601String(),
'i' => $last->id,
]));
}
return [$rows, null, $next];
}
// Backward-compat OFFSET-путь: total + offset для существующего frontend.
// +1 fetch trick — узнаём про следующую страницу одним SELECT'ом без COUNT.
$total = (clone $query)->count();
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
->limit($limit + 1)->offset($offset)->get();
$hasNext = $rows->count() > $limit;
if ($hasNext) {
$rows = $rows->slice(0, $limit)->values();
}
$next = null;
if ($hasNext && $rows->isNotEmpty()) {
$last = $rows->last();
$next = base64_encode((string) json_encode([
'r' => $last->received_at?->toIso8601String(),
'i' => $last->id,
]));
}
return [$rows, $total, $next];
});
if ($countOnly) {
return response()->json(['total' => $total]);
}
$payload = [
'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(),
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
];
if ($cursor === null) {
$payload['total'] = $total;
$payload['offset'] = $offset;
}
return response()->json($payload);
}
/**
* GET /api/deals/{id} — детали сделки + recent activity events.
*
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
* relations + до 50 последних activity_log событий по этой сделке.
*
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
* принадлежит tenant'у (или не существует) — 404.
*/
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
->where('tenant_id', $tenantId)
->where('id', $id)
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
->first();
if ($deal === null) {
return [null, []];
}
$events = ActivityLog::query()
->where('tenant_id', $tenantId)
->where('deal_id', $id)
->with('user:id,email,first_name,last_name')
->orderByDesc('created_at')
->orderByDesc('id')
->limit(50)
->get();
return [$deal, $events];
});
if ($deal === null) {
return response()->json(['message' => 'Сделка не найдена.'], 404);
}
return response()->json([
'deal' => [
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => $deal->project?->name,
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
'status' => $deal->status,
'manager_id' => $deal->manager_id,
'manager_name' => $deal->manager
? ManagerController::formatName($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
: null,
'manager_initials' => $deal->manager
? ManagerController::formatInitials($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
: null,
'received_at' => $deal->received_at?->toIso8601String(),
'assigned_at' => $deal->assigned_at?->toIso8601String(),
],
'events' => $events->map(fn (ActivityLog $e) => [
'id' => $e->id,
'event' => $e->event,
'context' => $e->context,
'created_at' => $e->created_at?->toIso8601String(),
'actor' => $e->user
? [
'id' => $e->user->id,
'name' => ManagerController::formatName($e->user->first_name, $e->user->last_name, $e->user->email),
'initials' => ManagerController::formatInitials($e->user->first_name, $e->user->last_name, $e->user->email),
]
: null,
]),
]);
}
/**
* PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer.
*
* Body (все поля optional, должно быть хотя бы одно): {comment?,
* manager_id?, status?}.
*
* Каждое изменение пишется в ActivityLog с правильным event-type:
* - comment → deal.commented (context.text — новый комментарий полностью)
* - manager_id → deal.assigned (context.from/to)
* - status → deal.status_changed (context.from/to/source='manual')
*
* NO-OP (значение не меняется) — ActivityLog НЕ пишется.
*
* Manager FK guard: новый manager_id должен принадлежать tenant'у (как в store).
* Status validation: slug должен существовать в lead_statuses (как в transition).
*
* RLS + defense-in-depth where(tenant_id) — 404 если сделка чужая.
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'comment' => 'nullable|string|max:5000',
'manager_id' => 'nullable|integer|min:1',
'status' => 'nullable|string|max:50',
]);
$tenantId = (int) $request->user()->tenant_id;
// Validate status slug если передан.
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
if (! $statusExists) {
return response()->json([
'message' => 'Неизвестный статус.',
'errors' => ['status' => ['Slug не найден в lead_statuses.']],
], 422);
}
}
// Manager FK guard.
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $managerExists) {
return response()->json([
'message' => 'Менеджер не найден в этом тенанте.',
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
->where('tenant_id', $tenantId)
->where('id', $id)
->first();
if ($deal === null) {
return null;
}
// Применяем изменения с записью ActivityLog для каждого изменённого поля.
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
$deal->comment = $validated['comment'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => 'deal.commented',
'context' => ['text' => $validated['comment'] ?? ''],
]);
}
if (array_key_exists('manager_id', $validated) && $deal->manager_id !== $validated['manager_id']) {
$previousManager = $deal->manager_id;
$deal->manager_id = $validated['manager_id'];
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
]);
}
if (array_key_exists('status', $validated) && $validated['status'] !== null && $deal->status !== $validated['status']) {
$previousStatus = $deal->status;
$deal->status = $validated['status'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
]);
}
$deal->save();
return $deal;
});
if ($deal === null) {
return response()->json(['message' => 'Сделка не найдена.'], 404);
}
return response()->json([
'deal' => [
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
'status' => $deal->status,
'manager_id' => $deal->manager_id,
'received_at' => $deal->received_at?->toIso8601String(),
'assigned_at' => $deal->assigned_at?->toIso8601String(),
],
]);
}
/** POST /api/deals — manual create */
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'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',
]);
$tenantId = (int) $request->user()->tenant_id;
// Manager FK guard: если manager_id передан, он должен принадлежать
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
if (isset($validated['manager_id'])) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenantId)
->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, $tenantId, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
['type' => 'manual'],
);
$deal = Deal::create([
'tenant_id' => $tenantId,
'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' => $tenantId,
'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);
}
}