dffefe7fc0
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.
576 lines
26 KiB
PHP
576 lines
26 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\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);
|
||
}
|
||
}
|