Files
portal/app/app/Http/Controllers/Api/DealController.php
T
Дмитрий dffefe7fc0 docs(billing): Phase 3 cleanup — refresh orphan comments to live classes
After ProcessWebhookJob/WebhookReceiveController removal — обновлены 8
docblock/inline комментариев, ссылавшихся на удалённый код:

- DealController: ProcessWebhookJob → SupplierWebhookController/RouteSupplierLeadJob
- SupplierWebhookController: убрана legacy backward-compat note
- ImportLeadsJob: паритет с RouteSupplierLeadJob
- RouteSupplierLeadJob: убрана ссылка на ProcessWebhookJob-pattern
- NewLeadNotification mailable: триггер в RouteSupplierLeadJob
- FailedWebhookJob model: ссылка на RouteSupplierLeadJob::failed()
- SupplierLeadCost model: создаётся в LedgerService::chargeForDelivery
- CsvLeadsParser: паритет с RouteSupplierLeadJob парсером

Code-функциональность не затронута, только doc-rot fix.
2026-05-24 18:51:16 +03:00

576 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
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
* `SupplierWebhookController` + `RouteSupplierLeadJob` (шеринг-канал).
* Этот 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;
$request->validate([
'received_from' => 'nullable|date',
'received_to' => 'nullable|date',
]);
$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', '');
$receivedFrom = trim((string) $request->query('received_from', ''));
$receivedTo = trim((string) $request->query('received_to', ''));
// 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, $receivedFrom, $receivedTo) {
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()
->select('deals.*')
->addSelect(['next_reminder_at' => DB::table('reminders')
->select('remind_at')
->whereColumn('reminders.deal_id', 'deals.id')
->whereNull('reminders.completed_at')
->orderBy('remind_at')
->limit(1),
])
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', '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);
});
}
if ($receivedFrom !== '') {
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
}
if ($receivedTo !== '') {
// received_to включительно — до конца дня (+1 день, строгое <).
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
}
// 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(),
'comment' => $d->comment,
'city' => $d->city,
'project_signal_type' => $d->project?->signal_type,
'project_signal_identifier' => $d->project?->signal_identifier,
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'next_reminder_at' => $d->next_reminder_at
? Carbon::parse($d->next_reminder_at)->toIso8601String()
: null,
]),
'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, PdAuditLogger $pdLog): 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,signal_type,signal_identifier,sms_keyword,sms_senders', '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);
}
$pdLog->record(
action: 'viewed',
subjectType: 'lead',
subjectId: $deal->id,
purpose: 'lead_card_view',
tenantId: (int) $request->user()->tenant_id,
actorTenantUserId: (int) $request->user()->id,
actorAdminUserId: null,
ip: $request->ip(),
);
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(),
'project_signal_type' => $deal->project?->signal_type,
'project_signal_identifier' => $deal->project?->signal_identifier,
'project_sms_keyword' => $deal->project?->sms_keyword,
'project_sms_senders' => $deal->project?->sms_senders,
],
'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' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => 'deal.commented',
'context' => ['text' => $validated['comment'] ?? ''],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
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' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
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' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
$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(),
'project_signal_type' => $deal->project?->signal_type,
'project_signal_identifier' => $deal->project?->signal_identifier,
'project_sms_keyword' => $deal->project?->sms_keyword,
'project_sms_senders' => $deal->project?->sms_senders,
],
]);
}
/** POST /api/deals — manual create */
public function store(Request $request, PdAuditLogger $pdLog): 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' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return $deal;
});
$pdLog->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_manual', tenantId: (int) $deal->tenant_id,
actorTenantUserId: (int) $request->user()->id,
actorAdminUserId: null, ip: $request->ip(),
);
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);
}
}