2026-05-09 06:43:21 +03:00
|
|
|
|
<?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;
|
2026-05-09 06:58:49 +03:00
|
|
|
|
use App\Models\SupplierLeadCost;
|
|
|
|
|
|
use App\Models\User;
|
2026-05-09 07:11:44 +03:00
|
|
|
|
use App\Services\SupplierResolver;
|
2026-05-09 06:43:21 +03:00
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-09 20:08:20 +03:00
|
|
|
|
* Сделки — 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 по одной записи.
|
2026-05-09 06:43:21 +03:00
|
|
|
|
*
|
|
|
|
|
|
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
|
|
|
|
|
|
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
|
|
|
|
|
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
|
|
|
|
|
*
|
2026-05-16 15:14:17 +03:00
|
|
|
|
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
2026-05-09 06:43:21 +03:00
|
|
|
|
*
|
|
|
|
|
|
* 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
|
|
|
|
|
|
{
|
2026-05-09 07:34:39 +03:00
|
|
|
|
/**
|
2026-05-16 15:14:17 +03:00
|
|
|
|
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
|
2026-05-09 07:34:39 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Список сделок 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
|
|
|
|
|
|
{
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
2026-05-09 07:34:39 +03:00
|
|
|
|
|
|
|
|
|
|
$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'));
|
2026-05-09 10:27:11 +03:00
|
|
|
|
$onlyDeleted = $request->boolean('only_deleted');
|
2026-05-17 03:11:03 +03:00
|
|
|
|
$countOnly = $request->boolean('count_only');
|
2026-05-10 04:29:54 +03:00
|
|
|
|
$cursorRaw = (string) $request->query('cursor', '');
|
2026-05-09 07:34:39 +03:00
|
|
|
|
|
2026-05-10 04:29:54 +03:00
|
|
|
|
// 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']];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 03:11:03 +03:00
|
|
|
|
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
|
2026-05-09 07:34:39 +03:00
|
|
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
|
|
|
|
|
// через `postgres` superuser RLS обходится BYPASSRLS, app-фильтр
|
|
|
|
|
|
// гарантирует изоляцию даже при misconfig RLS.
|
2026-05-09 10:27:11 +03:00
|
|
|
|
//
|
|
|
|
|
|
// only_deleted=true: «Корзина» — показываем только soft-deleted.
|
|
|
|
|
|
// withTrashed() обходит global scope SoftDeletes; явный
|
|
|
|
|
|
// whereNotNull('deleted_at') фильтрует только удалённые.
|
2026-05-09 07:34:39 +03:00
|
|
|
|
$query = Deal::query()
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
|
|
|
|
|
|
2026-05-09 10:27:11 +03:00
|
|
|
|
if ($onlyDeleted) {
|
|
|
|
|
|
$query->withTrashed()->whereNotNull('deleted_at');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 07:34:39 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 03:11:03 +03:00
|
|
|
|
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
|
|
|
|
|
|
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
|
|
|
|
|
|
if ($countOnly) {
|
|
|
|
|
|
return [collect(), $query->count(), null];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 04:29:54 +03:00
|
|
|
|
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.
|
2026-05-09 07:34:39 +03:00
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
|
|
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
2026-05-10 04:29:54 +03:00
|
|
|
|
->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,
|
|
|
|
|
|
]));
|
|
|
|
|
|
}
|
2026-05-09 07:34:39 +03:00
|
|
|
|
|
2026-05-10 04:29:54 +03:00
|
|
|
|
return [$rows, $total, $next];
|
2026-05-09 07:34:39 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-17 03:11:03 +03:00
|
|
|
|
if ($countOnly) {
|
|
|
|
|
|
return response()->json(['total' => $total]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 04:29:54 +03:00
|
|
|
|
$payload = [
|
2026-05-09 07:34:39 +03:00
|
|
|
|
'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,
|
2026-05-10 04:29:54 +03:00
|
|
|
|
'next_cursor' => $nextCursor,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if ($cursor === null) {
|
|
|
|
|
|
$payload['total'] = $total;
|
|
|
|
|
|
$payload['offset'] = $offset;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response()->json($payload);
|
2026-05-09 07:34:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 08:21:50 +03:00
|
|
|
|
/**
|
2026-05-16 15:14:17 +03:00
|
|
|
|
* GET /api/deals/{id} — детали сделки + recent activity events.
|
2026-05-09 08:21:50 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
|
|
|
|
|
|
* relations + до 50 последних activity_log событий по этой сделке.
|
|
|
|
|
|
*
|
|
|
|
|
|
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
|
|
|
|
|
|
* принадлежит tenant'у (или не существует) — 404.
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function show(Request $request, int $id): JsonResponse
|
|
|
|
|
|
{
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
2026-05-09 08:21:50 +03:00
|
|
|
|
|
|
|
|
|
|
[$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,
|
|
|
|
|
|
]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:10:58 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer.
|
|
|
|
|
|
*
|
2026-05-16 15:14:17 +03:00
|
|
|
|
* Body (все поля optional, должно быть хотя бы одно): {comment?,
|
2026-05-09 09:10:58 +03:00
|
|
|
|
* 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',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
2026-05-09 09:10:58 +03:00
|
|
|
|
|
|
|
|
|
|
// 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'])
|
2026-05-16 15:14:17 +03:00
|
|
|
|
->where('tenant_id', $tenantId)
|
2026-05-09 09:10:58 +03:00
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
|
->exists();
|
|
|
|
|
|
if (! $managerExists) {
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
|
'message' => 'Менеджер не найден в этом тенанте.',
|
|
|
|
|
|
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
|
|
|
|
|
], 422);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
|
|
|
|
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
2026-05-09 09:10:58 +03:00
|
|
|
|
|
|
|
|
|
|
$deal = Deal::query()
|
2026-05-16 15:14:17 +03:00
|
|
|
|
->where('tenant_id', $tenantId)
|
2026-05-09 09:10:58 +03:00
|
|
|
|
->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([
|
2026-05-16 15:14:17 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-09 09:10:58 +03:00
|
|
|
|
'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([
|
2026-05-16 15:14:17 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-09 09:10:58 +03:00
|
|
|
|
'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([
|
2026-05-16 15:14:17 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-09 09:10:58 +03:00
|
|
|
|
'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(),
|
|
|
|
|
|
],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
/** 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',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$tenantId = (int) $request->user()->tenant_id;
|
2026-05-09 06:43:21 +03:00
|
|
|
|
|
2026-05-09 06:58:49 +03:00
|
|
|
|
// Manager FK guard: если manager_id передан, он должен принадлежать
|
|
|
|
|
|
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
|
|
|
|
|
|
if (isset($validated['manager_id'])) {
|
|
|
|
|
|
$managerExists = User::query()
|
|
|
|
|
|
->where('id', $validated['manager_id'])
|
2026-05-16 15:14:17 +03:00
|
|
|
|
->where('tenant_id', $tenantId)
|
2026-05-09 06:58:49 +03:00
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
|
->exists();
|
|
|
|
|
|
if (! $managerExists) {
|
|
|
|
|
|
return response()->json([
|
|
|
|
|
|
'message' => 'Менеджер не найден в этом тенанте.',
|
|
|
|
|
|
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
|
|
|
|
|
], 422);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
$statusSlug = $validated['status'] ?? 'new';
|
|
|
|
|
|
|
|
|
|
|
|
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$deal = DB::transaction(function () use ($validated, $tenantId, $statusSlug) {
|
|
|
|
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
2026-05-09 06:43:21 +03:00
|
|
|
|
|
|
|
|
|
|
$project = Project::firstOrCreate(
|
2026-05-16 15:14:17 +03:00
|
|
|
|
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
|
2026-05-09 06:43:21 +03:00
|
|
|
|
['type' => 'manual'],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
$deal = Deal::create([
|
2026-05-16 15:14:17 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-09 06:43:21 +03:00
|
|
|
|
'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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-09 06:58:49 +03:00
|
|
|
|
// SupplierLeadCost для manual-leads — если у проекта есть активный
|
|
|
|
|
|
// supplier через project_suppliers (m2m). Manual НЕ списывает
|
|
|
|
|
|
// баланс (Ю-2: реселлерская модель работает только при закупке у
|
|
|
|
|
|
// supplier'а), но cost-аналитика всё равно нужна — owner проекта
|
|
|
|
|
|
// мог самостоятельно купить лид и ввести руками.
|
2026-05-09 07:11:44 +03:00
|
|
|
|
$resolver = app(SupplierResolver::class);
|
|
|
|
|
|
$supplierId = $resolver->resolveForProject($project);
|
2026-05-09 06:58:49 +03:00
|
|
|
|
if ($supplierId !== null) {
|
|
|
|
|
|
SupplierLeadCost::create([
|
|
|
|
|
|
'deal_id' => $deal->id,
|
|
|
|
|
|
'received_at' => $deal->received_at,
|
|
|
|
|
|
'supplier_id' => $supplierId,
|
2026-05-09 07:11:44 +03:00
|
|
|
|
'cost_rub' => $resolver->costRubSnapshot($supplierId),
|
2026-05-09 06:58:49 +03:00
|
|
|
|
'supplier_lead_id' => null, // manual: нет внешнего id
|
|
|
|
|
|
'created_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
ActivityLog::create([
|
2026-05-16 15:14:17 +03:00
|
|
|
|
'tenant_id' => $tenantId,
|
2026-05-09 06:43:21 +03:00
|
|
|
|
'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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|